Initial commit: RemSound v1.0
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// About dialog. Shows the running version, a short blurb about RemSound and the latest
|
||||
/// release notes (built in below — bumped per release alongside the project's
|
||||
/// <see cref="System.Version"/> property).
|
||||
///
|
||||
/// Layout follows the same NVDA-friendly conventions the rest of the app uses: a small
|
||||
/// modal dialog with a heading label, a read-only multi-line text box for the notes that
|
||||
/// the user can tab into and arrow through, and a Close button as the AcceptButton /
|
||||
/// CancelButton. Escape dismisses.
|
||||
/// </summary>
|
||||
internal sealed class AboutDialog : Form
|
||||
{
|
||||
/// <summary>Markdown-ish release notes shown in the About box's scrolling text area.
|
||||
/// Bumped per release. Keep it short — the canonical release notes also live on the
|
||||
/// GitHub Releases page, which the user can reach via the Help menu's "Check for
|
||||
/// updates" path.</summary>
|
||||
private const string ReleaseNotes =
|
||||
"""
|
||||
RemSound v1.0
|
||||
|
||||
Initial public release.
|
||||
|
||||
Highlights:
|
||||
* Low-latency peer-to-peer audio over UDP. WASAPI for any Windows audio device,
|
||||
and a parallel ASIO lane for pro audio interfaces (Audient, Komplete Audio,
|
||||
Focusrite, RME). Each lane keeps its own native callback latency.
|
||||
* Pick an ASIO driver from the dropdown at the top of the Audio inputs and outputs
|
||||
tab to bring ASIO into the pipeline; select "(none)" to run WASAPI-only.
|
||||
* Profile system. Save your entire setup — device ticks, peers, codec, latency
|
||||
targets, hotkeys, ASIO driver choice — into a JSON file. Pick which profile to
|
||||
load at every launch.
|
||||
* Continuous auto-tune on either lane. Watches receive jitter and nudges the
|
||||
latency target up or down to stay click-free without forcing you to overshoot.
|
||||
* Opus inband FEC. Single-packet losses recover transparently in both Opus modes;
|
||||
you don't hear them at all. PCM is also available for clean LAN connections.
|
||||
* Remote control. Configurable global hotkeys can nudge a peer's RemSound volume
|
||||
or their Windows default-output-device master volume, opt-in on the receiver.
|
||||
* Built-in self-updater. Optionally polls GitHub for newer releases on a schedule
|
||||
you set; can install them silently if you want.
|
||||
|
||||
See the user manual (Help menu, or F1 from anywhere in the app) for full details
|
||||
on every control, the keyboard shortcuts, and the troubleshooting guide.
|
||||
""";
|
||||
|
||||
public AboutDialog()
|
||||
{
|
||||
Text = "About RemSound";
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MinimizeBox = false;
|
||||
MaximizeBox = false;
|
||||
ShowInTaskbar = false;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
KeyPreview = true;
|
||||
ClientSize = new Size(560, 420);
|
||||
|
||||
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "?";
|
||||
|
||||
var headingLabel = new Label
|
||||
{
|
||||
Text = $"RemSound version {version}",
|
||||
AutoSize = true,
|
||||
Font = new Font(SystemFonts.MessageBoxFont!.FontFamily, 11f, FontStyle.Bold),
|
||||
AccessibleName = $"RemSound version {version}",
|
||||
};
|
||||
|
||||
var notesBox = new TextBox
|
||||
{
|
||||
Multiline = true,
|
||||
ReadOnly = true,
|
||||
TabStop = true,
|
||||
Dock = DockStyle.Fill,
|
||||
ScrollBars = ScrollBars.Vertical,
|
||||
BorderStyle = BorderStyle.FixedSingle,
|
||||
Text = ReleaseNotes,
|
||||
AccessibleName = "Release notes (tab into and arrow to read)",
|
||||
};
|
||||
|
||||
var closeButton = new Button
|
||||
{
|
||||
Text = "Close",
|
||||
AutoSize = true,
|
||||
DialogResult = DialogResult.OK,
|
||||
TabIndex = 1,
|
||||
};
|
||||
closeButton.Click += (_, _) => Close();
|
||||
notesBox.TabIndex = 0;
|
||||
|
||||
var root = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
Padding = new Padding(12),
|
||||
ColumnCount = 1,
|
||||
RowCount = 3,
|
||||
};
|
||||
root.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
|
||||
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
|
||||
var buttons = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
FlowDirection = FlowDirection.RightToLeft,
|
||||
AutoSize = true,
|
||||
};
|
||||
buttons.Controls.Add(closeButton);
|
||||
|
||||
root.Controls.Add(headingLabel, 0, 0);
|
||||
root.Controls.Add(notesBox, 0, 1);
|
||||
root.Controls.Add(buttons, 0, 2);
|
||||
Controls.Add(root);
|
||||
|
||||
AcceptButton = closeButton;
|
||||
CancelButton = closeButton;
|
||||
|
||||
KeyDown += (_, e) =>
|
||||
{
|
||||
if (e.KeyCode == Keys.Escape)
|
||||
{
|
||||
Close();
|
||||
e.SuppressKeyPress = true;
|
||||
e.Handled = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Direct NotifyWinEvent shim. WinForms focus changes nominally fire MSAA EVENT_OBJECT_FOCUS,
|
||||
/// but in some scenarios (focus moving from a key handler that runs synchronously in
|
||||
/// ProcessCmdKey, focus into a control inside a wrapper container, etc.) NVDA's screen-reader
|
||||
/// listener doesn't pick up the announcement. Re-firing the event explicitly forces it.
|
||||
///
|
||||
/// Same pattern documented in claude-notes.md for the AccessibleCheckBox state-change fix —
|
||||
/// "the load-bearing piece is the FOCUS re-fire."
|
||||
/// </summary>
|
||||
internal static class WinEventNotifier
|
||||
{
|
||||
private const uint EVENT_OBJECT_FOCUS = 0x8005;
|
||||
private const int OBJID_CLIENT = unchecked((int)0xFFFFFFFC);
|
||||
private const int CHILDID_SELF = 0;
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern void NotifyWinEvent(uint eventMin, nint hwnd, int idObject, int idChild);
|
||||
|
||||
public static void NotifyFocus(Control control)
|
||||
{
|
||||
if (control.IsHandleCreated)
|
||||
{
|
||||
NotifyWinEvent(EVENT_OBJECT_FOCUS, control.Handle, OBJID_CLIENT, CHILDID_SELF);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CheckBox variant that fires the right MSAA WinEvents on every state change so NVDA
|
||||
/// reliably announces "checked" / "not checked" — including for spacebar toggles while the
|
||||
/// checkbox already has focus, which is the failure mode plain WinForms CheckBox has on
|
||||
/// .NET 10. The recipe (proven in the loxone desktop app):
|
||||
/// 1. Fire EVENT_OBJECT_STATECHANGE so any listener knows the toggle state changed.
|
||||
/// 2. If the checkbox is currently focused, ALSO re-fire EVENT_OBJECT_FOCUS — this is what
|
||||
/// forces NVDA to re-announce the focused control, bringing the new state with it.
|
||||
/// We call <c>user32.NotifyWinEvent</c> directly because the managed
|
||||
/// <see cref="Control.AccessibilityNotifyClients"/> path only fires the state event without
|
||||
/// the focus re-fire, which leaves NVDA silent.
|
||||
/// </summary>
|
||||
internal sealed class AccessibleCheckBox : CheckBox
|
||||
{
|
||||
private const uint EVENT_OBJECT_FOCUS = 0x8005;
|
||||
private const uint EVENT_OBJECT_STATECHANGE = 0x800A;
|
||||
private const int OBJID_CLIENT = unchecked((int)0xFFFFFFFC);
|
||||
private const int CHILDID_SELF = 0;
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern void NotifyWinEvent(uint eventMin, nint hwnd, int idObject, int idChild);
|
||||
|
||||
protected override void OnCheckedChanged(EventArgs e)
|
||||
{
|
||||
base.OnCheckedChanged(e);
|
||||
if (!IsHandleCreated) return;
|
||||
|
||||
NotifyWinEvent(EVENT_OBJECT_STATECHANGE, Handle, OBJID_CLIENT, CHILDID_SELF);
|
||||
if (Focused)
|
||||
{
|
||||
NotifyWinEvent(EVENT_OBJECT_FOCUS, Handle, OBJID_CLIENT, CHILDID_SELF);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using NAudio.CoreAudioApi;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates currently active Windows audio endpoints, separately for output (render — used
|
||||
/// for loopback capture) and input (capture — mics, line-ins) devices. Used by the App to
|
||||
/// populate the two send-device check-lists.
|
||||
///
|
||||
/// Selection state is intentionally NOT persisted: every session starts with all checkboxes
|
||||
/// unticked and nothing being sent. The user re-ticks once per session. Stops the
|
||||
/// "wrong-device-still-checked" surprise after a card unplug, ID change, etc.
|
||||
/// </summary>
|
||||
internal static class AudioDeviceCatalog
|
||||
{
|
||||
public static IReadOnlyList<AudioDeviceChoice> LoadOutputs()
|
||||
{
|
||||
using var enumerator = new MMDeviceEnumerator();
|
||||
var devices = enumerator.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active).ToList();
|
||||
var choices = devices
|
||||
.Select(d => new AudioDeviceChoice(d.FriendlyName, d.ID, CaptureKind.Loopback))
|
||||
.ToList();
|
||||
foreach (var device in devices) device.Dispose();
|
||||
return choices;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<AudioDeviceChoice> LoadInputs()
|
||||
{
|
||||
using var enumerator = new MMDeviceEnumerator();
|
||||
var devices = enumerator.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active).ToList();
|
||||
var choices = devices
|
||||
.Select(d => new AudioDeviceChoice(d.FriendlyName, d.ID, CaptureKind.Input))
|
||||
.ToList();
|
||||
foreach (var device in devices) device.Dispose();
|
||||
return choices;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
namespace RemSound.App;
|
||||
|
||||
internal static class FormLayoutRows
|
||||
{
|
||||
public static void AddRow(TableLayoutPanel panel, int row, string labelText, Control control, Action<Control> focusControl)
|
||||
{
|
||||
var label = new MnemonicLabel { Text = labelText, AutoSize = true, Anchor = AnchorStyles.Left, MnemonicTarget = control };
|
||||
label.Click += (_, _) => focusControl(control);
|
||||
panel.Controls.Add(label, 0, row);
|
||||
panel.Controls.Add(control, 1, row);
|
||||
}
|
||||
|
||||
public static MnemonicLabel AddCheckedListRow(TableLayoutPanel panel, int row, string labelText, CheckedListBox list, Label statusLabel, Action<CheckedListBox> focusList)
|
||||
{
|
||||
// Restored the FlowLayoutPanel wrapping (matches the legacy/working RSound layout).
|
||||
// Removing it caused NVDA to mis-pair labels with controls (off-by-one shift across
|
||||
// the form), so the keyboard-shortcut announcements went to the wrong controls. The
|
||||
// wrapping isn't ideal for label.ProcessMnemonic forwarding, but ProcessCmdKey handles
|
||||
// the actual Alt+letter activation explicitly so we don't need to rely on that path.
|
||||
// Returns the label so callers can update its text on mode changes (e.g. ASIO ⇄ WASAPI).
|
||||
var label = new MnemonicLabel { Text = labelText, AutoSize = true, Anchor = AnchorStyles.Left, MnemonicTarget = list };
|
||||
label.Click += (_, _) => focusList(list);
|
||||
panel.Controls.Add(label, 0, row);
|
||||
var container = new FlowLayoutPanel
|
||||
{
|
||||
AutoSize = true,
|
||||
Dock = DockStyle.Fill,
|
||||
FlowDirection = FlowDirection.TopDown,
|
||||
WrapContents = false,
|
||||
TabStop = false,
|
||||
};
|
||||
container.Controls.Add(list);
|
||||
container.Controls.Add(statusLabel);
|
||||
panel.Controls.Add(container, 1, row);
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Form that exposes a delegate hook for ProcessCmdKey, so the dialog's Alt+letter activations
|
||||
/// can be wired up from the closure-based dialog construction code without subclassing Form per
|
||||
/// dialog type. Every Alt+key combo passes through the delegate first; if it returns true the
|
||||
/// keystroke is consumed.
|
||||
/// </summary>
|
||||
internal sealed class CmdKeyForm : Form
|
||||
{
|
||||
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
|
||||
public Func<Keys, bool>? CmdKeyHandler { get; set; }
|
||||
|
||||
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
|
||||
{
|
||||
if (CmdKeyHandler is { } handler && handler(keyData)) return true;
|
||||
return base.ProcessCmdKey(ref msg, keyData);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TabControl subclass that suppresses the parent role announcement NVDA picks up from the
|
||||
/// modern .NET 10 UIA exposure. The base class reports itself as a UIA Tab control type;
|
||||
/// NVDA reads "tab control" before each tab's name. By returning AccessibleRole.None from
|
||||
/// our custom AccessibleObject we hide the parent's role from screen readers entirely.
|
||||
///
|
||||
/// Andre's accessible-readout app reads cleanly because it's compiled against .NET Framework
|
||||
/// 4.x, whose WinForms TabControl exposes less detail to MSAA/UIA. .NET 10 added more, and
|
||||
/// Microsoft removed the opt-out — so this subclass is the only path.
|
||||
///
|
||||
/// Risk: dotnet/winforms#11831 was filed against .NET 8 reporting that overriding
|
||||
/// CreateAccessibilityInstance throws InvalidOperationException. Status uncertain on .NET 10.
|
||||
/// If we hit that exception at runtime, the fallback is to remove the override and accept
|
||||
/// the announcement.
|
||||
/// </summary>
|
||||
internal sealed class QuietTabControl : TabControl
|
||||
{
|
||||
protected override AccessibleObject CreateAccessibilityInstance()
|
||||
=> new QuietAcc(this);
|
||||
|
||||
private sealed class QuietAcc : ControlAccessibleObject
|
||||
{
|
||||
public QuietAcc(Control owner) : base(owner) { }
|
||||
// None hides the role from screen readers. NVDA falls through to the focused TabItem
|
||||
// child whose role is "tab" — and reads only that. No "tab control" prefix.
|
||||
public override AccessibleRole Role => AccessibleRole.None;
|
||||
// Empty name so NVDA doesn't read a parent name either.
|
||||
public override string? Name { get => string.Empty; set { } }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Label that forwards its Alt+letter mnemonic activation to an explicit target control rather
|
||||
/// than to "the next focusable control" (the default WinForms behaviour). Necessary because
|
||||
/// SelectNextControl is unreliable across container boundaries — when a list is wrapped in a
|
||||
/// FlowLayoutPanel for layout purposes, the default mnemonic walk skips past the panel and
|
||||
/// focuses whatever comes after it in the parent panel.
|
||||
/// </summary>
|
||||
internal sealed class MnemonicLabel : Label
|
||||
{
|
||||
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
|
||||
public Control? MnemonicTarget { get; set; }
|
||||
|
||||
protected override bool ProcessMnemonic(char charCode)
|
||||
{
|
||||
if (!UseMnemonic || !IsMnemonic(charCode, Text)) return false;
|
||||
if (MnemonicTarget is { } target && target.CanFocus)
|
||||
{
|
||||
target.Focus();
|
||||
// Force NVDA to re-announce. Same load-bearing pattern the AccessibleCheckBox uses
|
||||
// for state changes — the WinForms-built-in focus event isn't always picked up by
|
||||
// the screen reader, especially when focus moves into a control wrapped in a
|
||||
// FlowLayoutPanel via a synchronous Focus() call.
|
||||
WinEventNotifier.NotifyFocus(target);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Opens the bundled <c>readme.html</c> manual in the user's default browser. Wired to F1
|
||||
/// app-wide via <see cref="HelpKeyMessageFilter"/>, which is installed once at startup and
|
||||
/// catches F1 (without modifiers) before the message reaches any control. Works in every
|
||||
/// modal dialog and on the very first form the user sees (the profile picker), because the
|
||||
/// filter is registered before the first <c>ShowDialog</c>/<c>Application.Run</c> call.
|
||||
///
|
||||
/// File location: <c><exe>\readme.html</c> (resolved via <see cref="AppContext.BaseDirectory"/>).
|
||||
/// The .csproj copies it from the project root via a Content/Link rule so a fresh
|
||||
/// <c>dotnet publish</c> always lands a current copy next to the executable.
|
||||
/// </summary>
|
||||
internal static class HelpLauncher
|
||||
{
|
||||
/// <summary>Open the manual via Windows' shell association (default browser). Shows a
|
||||
/// MessageBox if the file is missing or shell-execute fails — better to surface an
|
||||
/// explanation than silently swallow the F1.</summary>
|
||||
public static void OpenManual()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "readme.html");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
MessageBox.Show(
|
||||
$"Manual not found at:\n\n{path}\n\nThe readme.html file should sit next to RemSound.exe. Re-publishing the build will restore it.",
|
||||
"Manual not found",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
// UseShellExecute=true is the load-bearing flag — it lets the OS pick the .html
|
||||
// handler (Edge / Chrome / Firefox / whatever the user defaulted). Without it
|
||||
// Process.Start would treat the .html as an executable and fail.
|
||||
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(
|
||||
$"Could not open the manual:\n\n{ex.Message}",
|
||||
"Manual open failed",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Install the F1-catches-help message filter on the current thread's message
|
||||
/// loop. Call once from <c>Program.Main</c> before any form is shown. Idempotent — calling
|
||||
/// it twice would register two filters which is wasteful but not harmful.</summary>
|
||||
public static void Install()
|
||||
{
|
||||
Application.AddMessageFilter(new HelpKeyMessageFilter());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Catches F1 keypresses anywhere in the application before they reach the focused control.
|
||||
/// Modifier-aware: bare F1 only — Ctrl+F1, Shift+F1, Alt+F1 fall through unchanged so we
|
||||
/// don't steal future combos. Single-instance state is fine because the filter chain is
|
||||
/// per-thread and RemSound is a single-threaded WinForms app.
|
||||
/// </summary>
|
||||
internal sealed class HelpKeyMessageFilter : IMessageFilter
|
||||
{
|
||||
private const int WM_KEYDOWN = 0x0100;
|
||||
private const int WM_SYSKEYDOWN = 0x0104;
|
||||
private const int VK_F1 = 0x70;
|
||||
|
||||
public bool PreFilterMessage(ref Message m)
|
||||
{
|
||||
if (m.Msg != WM_KEYDOWN && m.Msg != WM_SYSKEYDOWN) return false;
|
||||
if (m.WParam.ToInt32() != VK_F1) return false;
|
||||
// Bare F1 only — modifier combos are passed through. Lets a future "Shift+F1" do
|
||||
// something else (context help, etc.) without colliding with us.
|
||||
if ((Control.ModifierKeys & (Keys.Control | Keys.Shift | Keys.Alt)) != Keys.None) return false;
|
||||
HelpLauncher.OpenManual();
|
||||
return true; // consumed — no further dispatch
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
using System.Windows.Forms;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// CheckedListBox subclass that exposes the protected <c>RefreshItem</c> method publicly. Used
|
||||
/// for the connectivity dialog's "Connected peers" list, where each row's text is updated in
|
||||
/// place (live RTT, codec, direction) without re-adding items — that would destroy NVDA's row
|
||||
/// focus on every tick.
|
||||
/// </summary>
|
||||
internal sealed class LiveCheckedListBox : CheckedListBox
|
||||
{
|
||||
public void RefreshItemPublic(int index) => RefreshItem(index);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// User-facing choice that maps a friendly label to a codec + Opus frame size pair. The frame
|
||||
/// size is only meaningful when Codec == Opus; for PCM it's ignored.
|
||||
/// </summary>
|
||||
internal sealed record CodecChoice(string Label, AudioTransportCodec Codec, int OpusFrameMs)
|
||||
{
|
||||
public override string ToString() => Label;
|
||||
}
|
||||
|
||||
internal sealed record AudioDeviceChoice(string Name, string? DeviceId, CaptureKind Kind = CaptureKind.Loopback)
|
||||
{
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
internal sealed record RememberedPeerItem(string Entry)
|
||||
{
|
||||
public override string ToString() => Entry;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Live per-peer status surfaced in the connectivity dialog's listbox text. Mutated in place
|
||||
/// each tick by MainForm.SyncAllDialogPeerLists, then ListBox.RefreshItem(i) is called on the
|
||||
/// containing item so the visible label updates without rebuilding the listbox (which would
|
||||
/// destroy NVDA focus on the row).
|
||||
/// </summary>
|
||||
internal sealed class PeerLineStatus
|
||||
{
|
||||
public bool Connected;
|
||||
/// <summary>True when our sender is actively pushing audio at this peer.</summary>
|
||||
public bool Sending;
|
||||
/// <summary>True when audio is arriving from this peer's IP (a fresh receiver session exists).</summary>
|
||||
public bool Receiving;
|
||||
/// <summary>Codec label like "Opus 10ms", "Opus 20ms", "PCM". Null when not connected.</summary>
|
||||
public string? CodecLabel;
|
||||
/// <summary>Round-trip ping ms from heartbeat. Null when not connected or pending.</summary>
|
||||
public int? RttMs;
|
||||
}
|
||||
|
||||
internal sealed class PeerListItem
|
||||
{
|
||||
public PeerAnnouncement Peer { get; }
|
||||
public PeerLineStatus Status { get; } = new();
|
||||
|
||||
public PeerListItem(PeerAnnouncement peer) { Peer = peer; }
|
||||
|
||||
/// <summary>
|
||||
/// Stable identity for signature-based listbox-rebuild detection. Does NOT include live
|
||||
/// status — that gets updated in-place via RefreshItem so NVDA focus survives tick updates.
|
||||
/// </summary>
|
||||
public string StableKey() => $"{Peer.InstanceId}:{Peer.Name}:{Peer.Address}:{Peer.AudioPort}";
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
// Base label: "hostname (ip)" for discovered peers, just "ip" for manual-by-IP entries
|
||||
// (where hostname equals the IP address). Avoids "192.168.1.95 (192.168.1.95)" duplication.
|
||||
var addr = Peer.Address.ToString();
|
||||
var basePart = Peer.Name == addr ? addr : $"{Peer.Name} ({addr})";
|
||||
|
||||
if (!Status.Connected)
|
||||
{
|
||||
return basePart;
|
||||
}
|
||||
|
||||
// Connected line — extra metadata after a dash. Comma-separated so NVDA reads naturally:
|
||||
// "Andre's PC (1.2.3.4) — connected, Opus 10ms, send and receive, 32ms"
|
||||
var parts = new List<string> { "connected" };
|
||||
if (Status.CodecLabel is { Length: > 0 } codec) parts.Add(codec);
|
||||
|
||||
var direction = (Status.Sending, Status.Receiving) switch
|
||||
{
|
||||
(true, true) => "send and receive",
|
||||
(true, false) => "send only",
|
||||
(false, true) => "receive only",
|
||||
_ => null,
|
||||
};
|
||||
if (direction is not null) parts.Add(direction);
|
||||
|
||||
if (Status.RttMs is { } rtt) parts.Add($"{rtt}ms");
|
||||
|
||||
return $"{basePart} — {string.Join(", ", parts)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
internal sealed class MainFormHotkeyController : IDisposable
|
||||
{
|
||||
private readonly RemSoundSettingsStore settingsStore;
|
||||
private readonly Action toggleSend;
|
||||
private readonly Action toggleReceive;
|
||||
private readonly Action toggleTray;
|
||||
private readonly Action volumeUp;
|
||||
private readonly Action volumeDown;
|
||||
// Remote control hotkeys: trigger this machine to send a Control packet to its connected
|
||||
// peers. The local volume slider on this machine isn't touched — receivers that have opted
|
||||
// in handle the change. See Profile.AcceptRemoteVolumeCommands and the RemPacketType.Control
|
||||
// wire format.
|
||||
// * sendRemote* → adjust the receiver's RemSound app volume slider (in-app).
|
||||
// * sendSystem* → adjust the receiver's Windows default-output-device volume
|
||||
// (system-wide on the receiving machine — affects every app
|
||||
// there, including the screen reader).
|
||||
private readonly Action sendRemoteVolumeUp;
|
||||
private readonly Action sendRemoteVolumeDown;
|
||||
private readonly Action sendRemoteMuteToggle;
|
||||
private readonly Action sendSystemVolumeUp;
|
||||
private readonly Action sendSystemVolumeDown;
|
||||
private readonly Action sendSystemMuteToggle;
|
||||
private Form? owner;
|
||||
private HotkeyInfo sendMuteHotkey;
|
||||
private HotkeyInfo receiveMuteHotkey;
|
||||
private HotkeyInfo trayHotkey;
|
||||
private HotkeyInfo volumeUpHotkey;
|
||||
private HotkeyInfo volumeDownHotkey;
|
||||
private HotkeyInfo remoteVolumeUpHotkey;
|
||||
private HotkeyInfo remoteVolumeDownHotkey;
|
||||
private HotkeyInfo remoteMuteToggleHotkey;
|
||||
private HotkeyInfo systemVolumeUpHotkey;
|
||||
private HotkeyInfo systemVolumeDownHotkey;
|
||||
private HotkeyInfo systemMuteToggleHotkey;
|
||||
private GlobalHotkey? sendMuteGlobalHotkey;
|
||||
private GlobalHotkey? receiveMuteGlobalHotkey;
|
||||
private GlobalHotkey? trayGlobalHotkey;
|
||||
private GlobalHotkey? volumeUpGlobalHotkey;
|
||||
private GlobalHotkey? volumeDownGlobalHotkey;
|
||||
private GlobalHotkey? remoteVolumeUpGlobalHotkey;
|
||||
private GlobalHotkey? remoteVolumeDownGlobalHotkey;
|
||||
private GlobalHotkey? remoteMuteToggleGlobalHotkey;
|
||||
private GlobalHotkey? systemVolumeUpGlobalHotkey;
|
||||
private GlobalHotkey? systemVolumeDownGlobalHotkey;
|
||||
private GlobalHotkey? systemMuteToggleGlobalHotkey;
|
||||
|
||||
/// <summary>Optional log sink. MainForm wires this to <c>logFile.Event(...)</c> so each
|
||||
/// hotkey change writes a clear trail of "user opened capture", "captured X", "registered X
|
||||
/// successfully" / "registration FAILED with Win32 error N" to the diagnostic log. Lets
|
||||
/// us tell the difference between a capture that didn't fire, a save that didn't persist,
|
||||
/// and a Windows-side RegisterHotKey rejection.</summary>
|
||||
public Action<string>? Log { get; set; }
|
||||
|
||||
/// <summary>Optional callback fired when the user successfully captures and saves a new
|
||||
/// hotkey via the Keyboard shortcuts dialog. MainForm wires this to MarkProfileDirty so
|
||||
/// the unsaved-changes prompt fires on close and the user gets a Save reminder. Without
|
||||
/// this hook, hotkey edits silently bypass the dirty-flag and the user finds out their
|
||||
/// new bindings never made it into the profile JSON.</summary>
|
||||
public Action? OnHotkeyChanged { get; set; }
|
||||
|
||||
public MainFormHotkeyController(
|
||||
RemSoundSettingsStore settingsStore,
|
||||
Action toggleSend,
|
||||
Action toggleReceive,
|
||||
Action toggleTray,
|
||||
Action volumeUp,
|
||||
Action volumeDown,
|
||||
Action sendRemoteVolumeUp,
|
||||
Action sendRemoteVolumeDown,
|
||||
Action sendRemoteMuteToggle,
|
||||
Action sendSystemVolumeUp,
|
||||
Action sendSystemVolumeDown,
|
||||
Action sendSystemMuteToggle)
|
||||
{
|
||||
this.settingsStore = settingsStore;
|
||||
this.toggleSend = toggleSend;
|
||||
this.toggleReceive = toggleReceive;
|
||||
this.toggleTray = toggleTray;
|
||||
this.volumeUp = volumeUp;
|
||||
this.volumeDown = volumeDown;
|
||||
this.sendRemoteVolumeUp = sendRemoteVolumeUp;
|
||||
this.sendRemoteVolumeDown = sendRemoteVolumeDown;
|
||||
this.sendRemoteMuteToggle = sendRemoteMuteToggle;
|
||||
this.sendSystemVolumeUp = sendSystemVolumeUp;
|
||||
this.sendSystemVolumeDown = sendSystemVolumeDown;
|
||||
this.sendSystemMuteToggle = sendSystemMuteToggle;
|
||||
sendMuteHotkey = settingsStore.LoadSendMuteHotkey();
|
||||
receiveMuteHotkey = settingsStore.LoadReceiveMuteHotkey();
|
||||
trayHotkey = settingsStore.LoadTrayHotkey();
|
||||
volumeUpHotkey = settingsStore.LoadVolumeUpHotkey();
|
||||
volumeDownHotkey = settingsStore.LoadVolumeDownHotkey();
|
||||
remoteVolumeUpHotkey = settingsStore.LoadRemoteVolumeUpHotkey();
|
||||
remoteVolumeDownHotkey = settingsStore.LoadRemoteVolumeDownHotkey();
|
||||
remoteMuteToggleHotkey = settingsStore.LoadRemoteMuteToggleHotkey();
|
||||
systemVolumeUpHotkey = settingsStore.LoadSystemVolumeUpHotkey();
|
||||
systemVolumeDownHotkey = settingsStore.LoadSystemVolumeDownHotkey();
|
||||
systemMuteToggleHotkey = settingsStore.LoadSystemMuteToggleHotkey();
|
||||
}
|
||||
|
||||
public void Initialize(Form ownerForm)
|
||||
{
|
||||
owner = ownerForm;
|
||||
sendMuteGlobalHotkey = new GlobalHotkey(ownerForm);
|
||||
receiveMuteGlobalHotkey = new GlobalHotkey(ownerForm);
|
||||
trayGlobalHotkey = new GlobalHotkey(ownerForm);
|
||||
volumeUpGlobalHotkey = new GlobalHotkey(ownerForm);
|
||||
volumeDownGlobalHotkey = new GlobalHotkey(ownerForm);
|
||||
remoteVolumeUpGlobalHotkey = new GlobalHotkey(ownerForm);
|
||||
remoteVolumeDownGlobalHotkey = new GlobalHotkey(ownerForm);
|
||||
remoteMuteToggleGlobalHotkey = new GlobalHotkey(ownerForm);
|
||||
systemVolumeUpGlobalHotkey = new GlobalHotkey(ownerForm);
|
||||
systemVolumeDownGlobalHotkey = new GlobalHotkey(ownerForm);
|
||||
systemMuteToggleGlobalHotkey = new GlobalHotkey(ownerForm);
|
||||
sendMuteGlobalHotkey.Pressed += () => InvokeOnOwner(toggleSend);
|
||||
receiveMuteGlobalHotkey.Pressed += () => InvokeOnOwner(toggleReceive);
|
||||
trayGlobalHotkey.Pressed += () => InvokeOnOwner(toggleTray);
|
||||
volumeUpGlobalHotkey.Pressed += () => InvokeOnOwner(volumeUp);
|
||||
volumeDownGlobalHotkey.Pressed += () => InvokeOnOwner(volumeDown);
|
||||
remoteVolumeUpGlobalHotkey.Pressed += () => InvokeOnOwner(sendRemoteVolumeUp);
|
||||
remoteVolumeDownGlobalHotkey.Pressed += () => InvokeOnOwner(sendRemoteVolumeDown);
|
||||
remoteMuteToggleGlobalHotkey.Pressed += () => InvokeOnOwner(sendRemoteMuteToggle);
|
||||
systemVolumeUpGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemVolumeUp);
|
||||
systemVolumeDownGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemVolumeDown);
|
||||
systemMuteToggleGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemMuteToggle);
|
||||
RegisterSendMuteHotkey();
|
||||
RegisterReceiveMuteHotkey();
|
||||
RegisterTrayHotkey();
|
||||
RegisterVolumeUpHotkey();
|
||||
RegisterVolumeDownHotkey();
|
||||
RegisterRemoteVolumeUpHotkey();
|
||||
RegisterRemoteVolumeDownHotkey();
|
||||
RegisterRemoteMuteToggleHotkey();
|
||||
RegisterSystemVolumeUpHotkey();
|
||||
RegisterSystemVolumeDownHotkey();
|
||||
RegisterSystemMuteToggleHotkey();
|
||||
}
|
||||
|
||||
public void ShowKeyboardShortcutsDialog(IWin32Window dialogOwner)
|
||||
{
|
||||
// Modeled on the SpaceBlaster menu dialogs:
|
||||
// * A ListBox fills the dialog. Each row is one bindable hotkey shown as
|
||||
// "Action: current binding" — self-describing for NVDA on arrow-up/down.
|
||||
// * Enter on the list (or double-click) → opens the capture form for that row.
|
||||
// * Escape (or the Close button) closes the dialog.
|
||||
// * Tab cycles list → Close button. No "Change selected" intermediate button —
|
||||
// 2026-05-08 cleanup; the workflow is "arrow + Enter" exclusively, removing
|
||||
// the extra Tab-to-button step the user had to make for every change.
|
||||
//
|
||||
// Why a ListBox instead of one Button per row: the hotkey count grew to eleven
|
||||
// (5 local + 3 remote-app + 3 system-volume) and the per-row Button stack made
|
||||
// arrow-key / Tab navigation slow. ListBox is one focusable control with native
|
||||
// arrow-key navigation and NVDA reads each item as the selection moves — much
|
||||
// quicker to triage which binding you want to change.
|
||||
// CmdKeyForm gives us a ProcessCmdKey hook that runs BEFORE the form's
|
||||
// ProcessDialogKey path (which is what would fire AcceptButton on Enter). We
|
||||
// need that to make Enter-on-the-list rebind a hotkey instead of closing the
|
||||
// dialog. Without this, AcceptButton swallowed Enter regardless of which
|
||||
// control had focus and the user got bounced straight back to the Profiles
|
||||
// and preferences tab. (KeyPreview + the form-level KeyDown wasn't enough on
|
||||
// its own — that fires AFTER ProcessCmdKey/ProcessDialogKey, so AcceptButton
|
||||
// had already won.)
|
||||
using var dialog = new CmdKeyForm
|
||||
{
|
||||
Text = "Keyboard shortcuts",
|
||||
StartPosition = FormStartPosition.CenterParent,
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog,
|
||||
MinimizeBox = false,
|
||||
MaximizeBox = false,
|
||||
ShowInTaskbar = false,
|
||||
KeyPreview = true, // form-level Esc handler
|
||||
ClientSize = new Size(640, 440),
|
||||
};
|
||||
|
||||
var root = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
Padding = new Padding(12),
|
||||
ColumnCount = 1,
|
||||
RowCount = 3, // 0 intro, 1 list, 2 buttons
|
||||
};
|
||||
root.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
|
||||
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
|
||||
var introLabel = new Label
|
||||
{
|
||||
Text = "Arrow up and down to pick a shortcut. Press Enter to rebind it, Del to clear it. Escape closes the dialog.\n\n"
|
||||
+ "The remote-control rows send commands to connected peers; they only have an effect on peers that have 'Accept remote volume commands from peers' enabled.",
|
||||
AutoSize = true,
|
||||
MaximumSize = new Size(600, 0),
|
||||
Anchor = AnchorStyles.Left,
|
||||
};
|
||||
root.Controls.Add(introLabel, 0, 0);
|
||||
|
||||
var list = new ListBox
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
IntegralHeight = false,
|
||||
// NVDA reads this on first focus, then the per-item text on each arrow move.
|
||||
AccessibleName = "Keyboard shortcuts",
|
||||
TabIndex = 0,
|
||||
};
|
||||
root.Controls.Add(list, 0, 1);
|
||||
|
||||
var buttonsPanel = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
FlowDirection = FlowDirection.RightToLeft,
|
||||
AutoSize = true,
|
||||
Padding = new Padding(0, 8, 0, 0),
|
||||
};
|
||||
var closeButton = new Button { Text = "Close", AutoSize = true, DialogResult = DialogResult.OK, TabIndex = 1 };
|
||||
buttonsPanel.Controls.Add(closeButton);
|
||||
root.Controls.Add(buttonsPanel, 0, 2);
|
||||
|
||||
dialog.Controls.Add(root);
|
||||
|
||||
// The list rows correspond to the order below. Index → which Change* helper to call.
|
||||
// Stable ordering keeps the user's muscle memory between sessions: local hotkeys
|
||||
// first, then the remote-app trio, then the Windows system-volume trio.
|
||||
void RefreshList()
|
||||
{
|
||||
var prev = list.SelectedIndex;
|
||||
list.BeginUpdate();
|
||||
list.Items.Clear();
|
||||
list.Items.Add($"Toggle sending audio: {sendMuteHotkey}");
|
||||
list.Items.Add($"Toggle receiving audio: {receiveMuteHotkey}");
|
||||
list.Items.Add($"Show or hide window: {trayHotkey}");
|
||||
list.Items.Add($"Volume up for received sound on this machine: {volumeUpHotkey}");
|
||||
list.Items.Add($"Volume down for received sound on this machine: {volumeDownHotkey}");
|
||||
list.Items.Add($"Send remote volume up to peers: {remoteVolumeUpHotkey}");
|
||||
list.Items.Add($"Send remote volume down to peers: {remoteVolumeDownHotkey}");
|
||||
list.Items.Add($"Send remote receive mute toggle to peers: {remoteMuteToggleHotkey}");
|
||||
list.Items.Add($"Send Windows global volume up to peers: {systemVolumeUpHotkey}");
|
||||
list.Items.Add($"Send Windows global volume down to peers: {systemVolumeDownHotkey}");
|
||||
list.Items.Add($"Send Windows global mute toggle to peers: {systemMuteToggleHotkey}");
|
||||
if (prev >= 0 && prev < list.Items.Count)
|
||||
{
|
||||
list.SelectedIndex = prev;
|
||||
}
|
||||
else if (list.Items.Count > 0)
|
||||
{
|
||||
list.SelectedIndex = 0;
|
||||
}
|
||||
list.EndUpdate();
|
||||
}
|
||||
|
||||
void ChangeSelected()
|
||||
{
|
||||
// Pass `dialog` (the shortcuts dialog itself) as the modal owner of the
|
||||
// HotkeyCaptureForm — NOT the original `dialogOwner` (which is MainForm).
|
||||
// Original bug: when MainForm was the owner, the new capture form was modal
|
||||
// to MainForm rather than to the shortcuts dialog, which (a) put it behind
|
||||
// the still-modal-to-MainForm shortcuts dialog in the Z-order, sometimes
|
||||
// invisibly so, and (b) created two parallel modal-to-MainForm chains. With
|
||||
// `dialog` as the owner, the capture form sits cleanly on top of the
|
||||
// shortcuts dialog, the shortcuts dialog is correctly disabled while it's
|
||||
// showing, and focus returns to the shortcuts list when it closes.
|
||||
switch (list.SelectedIndex)
|
||||
{
|
||||
case 0: ChangeSendMuteHotkey(dialog); break;
|
||||
case 1: ChangeReceiveMuteHotkey(dialog); break;
|
||||
case 2: ChangeTrayHotkey(dialog); break;
|
||||
case 3: ChangeVolumeUpHotkey(dialog); break;
|
||||
case 4: ChangeVolumeDownHotkey(dialog); break;
|
||||
case 5: ChangeRemoteVolumeUpHotkey(dialog); break;
|
||||
case 6: ChangeRemoteVolumeDownHotkey(dialog); break;
|
||||
case 7: ChangeRemoteMuteToggleHotkey(dialog); break;
|
||||
case 8: ChangeSystemVolumeUpHotkey(dialog); break;
|
||||
case 9: ChangeSystemVolumeDownHotkey(dialog); break;
|
||||
case 10: ChangeSystemMuteToggleHotkey(dialog); break;
|
||||
default: return;
|
||||
}
|
||||
RefreshList();
|
||||
// Move focus back to the list so the user can immediately arrow to another
|
||||
// row without an extra Tab. Without this, focus stays on the Change button
|
||||
// (which is what was clicked / Enter'd) — which is fine but feels sticky.
|
||||
list.Focus();
|
||||
}
|
||||
|
||||
// Clear the selected row's binding (set it to "(not set)"). Mirrors the same path
|
||||
// the rebinding flow uses: writes Unset into the in-memory settings cache, calls
|
||||
// RegisterIfSet (which unregisters because the new info IsUnset), marks the
|
||||
// profile dirty, and refreshes the list display. NB: we do this from the list's
|
||||
// KeyDown rather than ProcessCmdKey because Del isn't intercepted by the form-
|
||||
// level AcceptButton dance and works fine via the standard event.
|
||||
void UnsetSelected()
|
||||
{
|
||||
switch (list.SelectedIndex)
|
||||
{
|
||||
case 0: ApplyUnset("send-mute", h => sendMuteHotkey = h, RegisterSendMuteHotkey, settingsStore.SaveSendMuteHotkey); break;
|
||||
case 1: ApplyUnset("receive-mute", h => receiveMuteHotkey = h, RegisterReceiveMuteHotkey, settingsStore.SaveReceiveMuteHotkey); break;
|
||||
case 2: ApplyUnset("tray", h => trayHotkey = h, RegisterTrayHotkey, settingsStore.SaveTrayHotkey); break;
|
||||
case 3: ApplyUnset("volume-up", h => volumeUpHotkey = h, RegisterVolumeUpHotkey, settingsStore.SaveVolumeUpHotkey); break;
|
||||
case 4: ApplyUnset("volume-down", h => volumeDownHotkey = h, RegisterVolumeDownHotkey, settingsStore.SaveVolumeDownHotkey); break;
|
||||
case 5: ApplyUnset("send-remote-volume-up", h => remoteVolumeUpHotkey = h, RegisterRemoteVolumeUpHotkey, settingsStore.SaveRemoteVolumeUpHotkey); break;
|
||||
case 6: ApplyUnset("send-remote-volume-down", h => remoteVolumeDownHotkey = h, RegisterRemoteVolumeDownHotkey, settingsStore.SaveRemoteVolumeDownHotkey); break;
|
||||
case 7: ApplyUnset("send-remote-mute-toggle", h => remoteMuteToggleHotkey = h, RegisterRemoteMuteToggleHotkey, settingsStore.SaveRemoteMuteToggleHotkey); break;
|
||||
case 8: ApplyUnset("send-system-volume-up", h => systemVolumeUpHotkey = h, RegisterSystemVolumeUpHotkey, settingsStore.SaveSystemVolumeUpHotkey); break;
|
||||
case 9: ApplyUnset("send-system-volume-down", h => systemVolumeDownHotkey = h, RegisterSystemVolumeDownHotkey, settingsStore.SaveSystemVolumeDownHotkey); break;
|
||||
case 10: ApplyUnset("send-system-mute-toggle", h => systemMuteToggleHotkey = h, RegisterSystemMuteToggleHotkey, settingsStore.SaveSystemMuteToggleHotkey); break;
|
||||
default: return;
|
||||
}
|
||||
RefreshList();
|
||||
list.Focus();
|
||||
}
|
||||
|
||||
// Helper for UnsetSelected — assign Unset to the field, re-register (which
|
||||
// unregisters since IsUnset is true), persist to the settings cache, log, and
|
||||
// mark the profile dirty so the close-prompt fires.
|
||||
void ApplyUnset(string description, Action<HotkeyInfo> setField, Action register, Action<HotkeyInfo> save)
|
||||
{
|
||||
setField(HotkeyInfo.Unset);
|
||||
register();
|
||||
save(HotkeyInfo.Unset);
|
||||
Log?.Invoke($"unset {description}: cleared (was bound, now (not set))");
|
||||
OnHotkeyChanged?.Invoke();
|
||||
}
|
||||
|
||||
// Enter-on-the-list rebinds via ProcessCmdKey at the form, so that the form's
|
||||
// AcceptButton dispatch (Close) doesn't get the keystroke first. ProcessCmdKey
|
||||
// runs ahead of ProcessDialogKey in WinForms' message pipeline; returning true
|
||||
// marks the key as consumed and the AcceptButton path is skipped. When focus
|
||||
// is anywhere else (e.g. the Close button) we let Enter fall through, so
|
||||
// Tab-to-Close + Enter still closes the dialog naturally.
|
||||
dialog.CmdKeyHandler = keyData =>
|
||||
{
|
||||
if (keyData != Keys.Enter) return false;
|
||||
if (dialog.ActiveControl != list) return false;
|
||||
ChangeSelected();
|
||||
return true;
|
||||
};
|
||||
list.DoubleClick += (_, _) => ChangeSelected();
|
||||
// Del on the list clears the highlighted binding back to "(not set)". No confirm
|
||||
// dialog — the user can rebind in two key presses (Enter + capture) if they hit Del
|
||||
// by mistake. Mirrors the SpaceBlaster-style "list + Del" idiom Ed asked for.
|
||||
list.KeyDown += (_, e) =>
|
||||
{
|
||||
if (e.KeyCode == Keys.Delete)
|
||||
{
|
||||
UnsetSelected();
|
||||
e.SuppressKeyPress = true;
|
||||
e.Handled = true;
|
||||
}
|
||||
};
|
||||
|
||||
dialog.KeyDown += (_, e) =>
|
||||
{
|
||||
if (e.KeyCode == Keys.Escape)
|
||||
{
|
||||
dialog.DialogResult = DialogResult.Cancel;
|
||||
dialog.Close();
|
||||
e.SuppressKeyPress = true;
|
||||
e.Handled = true;
|
||||
}
|
||||
};
|
||||
|
||||
RefreshList();
|
||||
// Enter on Close closes — works because list KeyDown above handled Enter when
|
||||
// focus was on the list. AcceptButton fires only when no control consumed Enter.
|
||||
dialog.AcceptButton = closeButton;
|
||||
dialog.CancelButton = closeButton;
|
||||
dialog.Load += (_, _) => list.Focus();
|
||||
dialog.ShowDialog(dialogOwner);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
sendMuteGlobalHotkey?.Dispose();
|
||||
receiveMuteGlobalHotkey?.Dispose();
|
||||
trayGlobalHotkey?.Dispose();
|
||||
volumeUpGlobalHotkey?.Dispose();
|
||||
volumeDownGlobalHotkey?.Dispose();
|
||||
remoteVolumeUpGlobalHotkey?.Dispose();
|
||||
remoteVolumeDownGlobalHotkey?.Dispose();
|
||||
remoteMuteToggleGlobalHotkey?.Dispose();
|
||||
systemVolumeUpGlobalHotkey?.Dispose();
|
||||
systemVolumeDownGlobalHotkey?.Dispose();
|
||||
systemMuteToggleGlobalHotkey?.Dispose();
|
||||
}
|
||||
|
||||
public HotkeyInfo SendMuteHotkey => sendMuteHotkey;
|
||||
public HotkeyInfo ReceiveMuteHotkey => receiveMuteHotkey;
|
||||
public HotkeyInfo TrayHotkey => trayHotkey;
|
||||
public HotkeyInfo VolumeUpHotkey => volumeUpHotkey;
|
||||
public HotkeyInfo VolumeDownHotkey => volumeDownHotkey;
|
||||
public HotkeyInfo RemoteVolumeUpHotkey => remoteVolumeUpHotkey;
|
||||
public HotkeyInfo RemoteVolumeDownHotkey => remoteVolumeDownHotkey;
|
||||
public HotkeyInfo RemoteMuteToggleHotkey => remoteMuteToggleHotkey;
|
||||
public HotkeyInfo SystemVolumeUpHotkey => systemVolumeUpHotkey;
|
||||
public HotkeyInfo SystemVolumeDownHotkey => systemVolumeDownHotkey;
|
||||
public HotkeyInfo SystemMuteToggleHotkey => systemMuteToggleHotkey;
|
||||
|
||||
/// <summary>Open the capture dialog, log what came back, and (on a successful capture)
|
||||
/// run <paramref name="apply"/> with the captured hotkey. Centralises the boilerplate
|
||||
/// the eleven per-row Change methods used to duplicate. The <paramref name="description"/>
|
||||
/// is what shows in the diagnostic log so a user / developer can see the trail of
|
||||
/// "capture send-system-volume-down: OK = Ctrl+Shift+Alt+J / register …: OK" or
|
||||
/// "capture …: cancelled (DialogResult=Cancel)" / "register …: FAILED Win32 1409".</summary>
|
||||
private void ChangeHotkey(IWin32Window dialogOwner, string description, Action<HotkeyInfo> apply)
|
||||
{
|
||||
using var dialog = new HotkeyCaptureForm();
|
||||
var result = dialog.ShowDialog(dialogOwner);
|
||||
if (result == DialogResult.OK && dialog.CapturedHotkey is not null)
|
||||
{
|
||||
Log?.Invoke($"capture {description}: OK = {dialog.CapturedHotkey}");
|
||||
apply(dialog.CapturedHotkey);
|
||||
// Mark the active profile dirty so the unsaved-changes prompt fires on close.
|
||||
// The previous design relied on MarkProfileDirty being called from each UI event
|
||||
// hook in MainForm — but the hotkey controller is its own object that doesn't
|
||||
// know about that flag. Without this callback, hotkey edits silently slipped
|
||||
// past the dirty-check and the user closed without being prompted to save.
|
||||
OnHotkeyChanged?.Invoke();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Detect the "low-level hook ate your combination" case. If the capture form
|
||||
// observed modifier presses but never received the non-modifier key the user
|
||||
// was trying to bind, something else (NVDA / NVDA Remote / AutoHotkey / similar
|
||||
// accessibility / hotkey-manager tool that hooks at WH_KEYBOARD_LL level) is
|
||||
// intercepting the combination before Windows can deliver it to our window.
|
||||
// RegisterHotKey would have succeeded if we'd ever reached that point, so the
|
||||
// existing 1409-style warning never fires for this case — that's why the user
|
||||
// saw "no popup" even though their combination genuinely was unusable.
|
||||
//
|
||||
// The popup is shown TopMost via the same Win32 path the register-warning uses,
|
||||
// so it's guaranteed visible regardless of modal-stack Z-order.
|
||||
Log?.Invoke($"capture {description}: cancelled (DialogResult={result}, sawModifier={dialog.SawAnyModifier}, sawNonModifier={dialog.SawAnyNonModifier})");
|
||||
if (dialog.SawAnyModifier && !dialog.SawAnyNonModifier)
|
||||
{
|
||||
Log?.Invoke($"capture {description}: warning user about likely low-level hook interception");
|
||||
ShowRegisterWarning(
|
||||
"RemSound saw your modifier keys (Ctrl, Shift, Alt) but never received the non-modifier key you were pressing with them.\n\n"
|
||||
+ "That almost always means another app on this machine — NVDA, NVDA Remote, AutoHotkey, or a similar tool — is intercepting that key combination at a low level, before it can reach RemSound. The combination is unusable as a RemSound hotkey on this PC until the conflicting tool is reconfigured or that combination is freed up.\n\n"
|
||||
+ "Try a different key combination.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ChangeSendMuteHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "send-mute", h =>
|
||||
{
|
||||
sendMuteHotkey = h;
|
||||
RegisterSendMuteHotkey();
|
||||
settingsStore.SaveSendMuteHotkey(h);
|
||||
});
|
||||
|
||||
private void ChangeReceiveMuteHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "receive-mute", h =>
|
||||
{
|
||||
receiveMuteHotkey = h;
|
||||
RegisterReceiveMuteHotkey();
|
||||
settingsStore.SaveReceiveMuteHotkey(h);
|
||||
});
|
||||
|
||||
private void ChangeTrayHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "tray", h =>
|
||||
{
|
||||
trayHotkey = h;
|
||||
RegisterTrayHotkey();
|
||||
settingsStore.SaveTrayHotkey(h);
|
||||
});
|
||||
|
||||
private void ChangeVolumeUpHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "volume-up", h =>
|
||||
{
|
||||
volumeUpHotkey = h;
|
||||
RegisterVolumeUpHotkey();
|
||||
settingsStore.SaveVolumeUpHotkey(h);
|
||||
});
|
||||
|
||||
private void ChangeVolumeDownHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "volume-down", h =>
|
||||
{
|
||||
volumeDownHotkey = h;
|
||||
RegisterVolumeDownHotkey();
|
||||
settingsStore.SaveVolumeDownHotkey(h);
|
||||
});
|
||||
|
||||
private void ChangeRemoteVolumeUpHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "send-remote-volume-up", h =>
|
||||
{
|
||||
remoteVolumeUpHotkey = h;
|
||||
RegisterRemoteVolumeUpHotkey();
|
||||
settingsStore.SaveRemoteVolumeUpHotkey(h);
|
||||
});
|
||||
|
||||
private void ChangeRemoteVolumeDownHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "send-remote-volume-down", h =>
|
||||
{
|
||||
remoteVolumeDownHotkey = h;
|
||||
RegisterRemoteVolumeDownHotkey();
|
||||
settingsStore.SaveRemoteVolumeDownHotkey(h);
|
||||
});
|
||||
|
||||
private void ChangeRemoteMuteToggleHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "send-remote-mute-toggle", h =>
|
||||
{
|
||||
remoteMuteToggleHotkey = h;
|
||||
RegisterRemoteMuteToggleHotkey();
|
||||
settingsStore.SaveRemoteMuteToggleHotkey(h);
|
||||
});
|
||||
|
||||
private void ChangeSystemVolumeUpHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "send-system-volume-up", h =>
|
||||
{
|
||||
systemVolumeUpHotkey = h;
|
||||
RegisterSystemVolumeUpHotkey();
|
||||
settingsStore.SaveSystemVolumeUpHotkey(h);
|
||||
});
|
||||
|
||||
private void ChangeSystemVolumeDownHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "send-system-volume-down", h =>
|
||||
{
|
||||
systemVolumeDownHotkey = h;
|
||||
RegisterSystemVolumeDownHotkey();
|
||||
settingsStore.SaveSystemVolumeDownHotkey(h);
|
||||
});
|
||||
|
||||
private void ChangeSystemMuteToggleHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "send-system-mute-toggle", h =>
|
||||
{
|
||||
systemMuteToggleHotkey = h;
|
||||
RegisterSystemMuteToggleHotkey();
|
||||
settingsStore.SaveSystemMuteToggleHotkey(h);
|
||||
});
|
||||
|
||||
// Hotkeys come in two flavours and need different Windows-side registration:
|
||||
// * Toggle hotkeys (mute, tray show/hide) — re-firing on hold would flip state back
|
||||
// and forth. Registered with MOD_NOREPEAT (allowRepeat=false). One press, one fire.
|
||||
// * Step hotkeys (volume up/down, both local-receive and remote-app and remote-system
|
||||
// variants) — holding the key is the natural way to ramp through a range. Registered
|
||||
// WITHOUT MOD_NOREPEAT so Windows fires WM_HOTKEY at the user's keyboard auto-repeat
|
||||
// rate, exactly mirroring how the physical volume keys feel. The remote-control
|
||||
// packet send-path is light-weight enough that a held key doesn't strain the link;
|
||||
// the receiver-side COM volume call is hoisted out of the COM-enumeration cost via
|
||||
// SystemVolumeHelper's cached endpoint reference.
|
||||
private void RegisterSendMuteHotkey() => RegisterIfSet(sendMuteGlobalHotkey, sendMuteHotkey, "toggle sending");
|
||||
private void RegisterReceiveMuteHotkey() => RegisterIfSet(receiveMuteGlobalHotkey, receiveMuteHotkey, "toggle receiving");
|
||||
private void RegisterTrayHotkey() => RegisterIfSet(trayGlobalHotkey, trayHotkey, "tray");
|
||||
private void RegisterVolumeUpHotkey() => RegisterIfSet(volumeUpGlobalHotkey, volumeUpHotkey, "volume up", allowRepeat: true);
|
||||
private void RegisterVolumeDownHotkey() => RegisterIfSet(volumeDownGlobalHotkey, volumeDownHotkey, "volume down", allowRepeat: true);
|
||||
private void RegisterRemoteVolumeUpHotkey() => RegisterIfSet(remoteVolumeUpGlobalHotkey, remoteVolumeUpHotkey, "send remote volume up", allowRepeat: true);
|
||||
private void RegisterRemoteVolumeDownHotkey() => RegisterIfSet(remoteVolumeDownGlobalHotkey, remoteVolumeDownHotkey, "send remote volume down", allowRepeat: true);
|
||||
private void RegisterRemoteMuteToggleHotkey() => RegisterIfSet(remoteMuteToggleGlobalHotkey, remoteMuteToggleHotkey, "send remote mute toggle");
|
||||
private void RegisterSystemVolumeUpHotkey() => RegisterIfSet(systemVolumeUpGlobalHotkey, systemVolumeUpHotkey, "send Windows global volume up", allowRepeat: true);
|
||||
private void RegisterSystemVolumeDownHotkey() => RegisterIfSet(systemVolumeDownGlobalHotkey, systemVolumeDownHotkey, "send Windows global volume down", allowRepeat: true);
|
||||
private void RegisterSystemMuteToggleHotkey() => RegisterIfSet(systemMuteToggleGlobalHotkey, systemMuteToggleHotkey, "send Windows global mute toggle");
|
||||
|
||||
private void RegisterIfSet(GlobalHotkey? globalHotkey, HotkeyInfo hotkey, string description, bool allowRepeat = false)
|
||||
{
|
||||
if (globalHotkey is null) return;
|
||||
globalHotkey.Unregister();
|
||||
if (hotkey.IsUnset)
|
||||
{
|
||||
Log?.Invoke($"register {description}: SKIPPED (unset)");
|
||||
return;
|
||||
}
|
||||
if (globalHotkey.Register(hotkey, allowRepeat))
|
||||
{
|
||||
Log?.Invoke($"register {description}: OK = {hotkey}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Win32 error 1409 = ERROR_HOTKEY_ALREADY_REGISTERED. Anything else is unusual
|
||||
// (e.g. invalid VK code, no handle). Logging the raw code lets us distinguish
|
||||
// "another app/process owns this combo" from genuine registration weirdness.
|
||||
var err = globalHotkey.LastWin32ErrorOnRegister;
|
||||
var hint = err switch
|
||||
{
|
||||
1409 => "another app or another RemSound process already registered this combo",
|
||||
_ => "Win32 error",
|
||||
};
|
||||
Log?.Invoke($"register {description}: FAILED = {hotkey} (Win32 error {err}: {hint})");
|
||||
ShowRegisterWarning($"Could not register {description} hotkey {hotkey}. " + (err == 1409
|
||||
? "Another app — or another running copy of RemSound — is already using that combo. The hotkey is saved in your profile, so the binding will take effect once the conflict is resolved."
|
||||
: $"Windows reported error {err}. The hotkey is saved in your profile but Windows didn't accept the registration."));
|
||||
}
|
||||
}
|
||||
|
||||
private void InvokeOnOwner(Action action)
|
||||
{
|
||||
if (owner is null || owner.IsDisposed) return;
|
||||
owner.BeginInvoke(action);
|
||||
}
|
||||
|
||||
private void ShowRegisterWarning(string message)
|
||||
{
|
||||
// Use the Win32 MessageBox API directly with MB_TOPMOST + MB_SETFOREGROUND so the
|
||||
// popup is guaranteed to sit above every other window on the desktop, including
|
||||
// any modal dialog stack RemSound currently has open. The previous WinForms
|
||||
// MessageBox.Show(parent, …) calls were sometimes hiding behind the still-modal
|
||||
// Keyboard shortcuts dialog — the user reported "no popup" when in fact the popup
|
||||
// had been created and then occluded.
|
||||
//
|
||||
// MB_SETFOREGROUND on its own is sometimes ignored by Windows under foreground-lock
|
||||
// rules, but MB_TOPMOST overrides that. Together they're the most reliable way to
|
||||
// get a hotkey-conflict warning into the user's face at the moment the conflict is
|
||||
// detected.
|
||||
var hwnd = (Form.ActiveForm?.Handle) ?? owner?.Handle ?? IntPtr.Zero;
|
||||
const uint MB_OK = 0x00000000;
|
||||
const uint MB_ICONWARNING = 0x00000030;
|
||||
const uint MB_TOPMOST = 0x00040000;
|
||||
const uint MB_SETFOREGROUND = 0x00010000;
|
||||
MessageBoxW(hwnd, message, "RemSound — hotkey conflict", MB_OK | MB_ICONWARNING | MB_TOPMOST | MB_SETFOREGROUND);
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int MessageBoxW(IntPtr hWnd, string lpText, string lpCaption, uint uType);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace RemSound.App;
|
||||
|
||||
internal sealed class MainFormTrayController : IDisposable
|
||||
{
|
||||
private readonly Form owner;
|
||||
private readonly NotifyIcon trayIcon = new();
|
||||
|
||||
public MainFormTrayController(Form owner, Action enableSending, Action enableReceiving, Action exit)
|
||||
{
|
||||
this.owner = owner;
|
||||
trayIcon.Text = "RemSound";
|
||||
trayIcon.Icon = SystemIcons.Application;
|
||||
trayIcon.Visible = false;
|
||||
trayIcon.DoubleClick += (_, _) => Restore();
|
||||
var menu = new ContextMenuStrip();
|
||||
menu.Items.Add("Show", null, (_, _) => Restore());
|
||||
menu.Items.Add("Enable sending", null, (_, _) => enableSending());
|
||||
menu.Items.Add("Enable receiving", null, (_, _) => enableReceiving());
|
||||
menu.Items.Add("Exit", null, (_, _) => exit());
|
||||
trayIcon.ContextMenuStrip = menu;
|
||||
}
|
||||
|
||||
public void Toggle()
|
||||
{
|
||||
if (owner.Visible && owner.WindowState != FormWindowState.Minimized) Minimize();
|
||||
else Restore();
|
||||
}
|
||||
|
||||
public void Restore()
|
||||
{
|
||||
owner.Show();
|
||||
owner.WindowState = FormWindowState.Normal;
|
||||
owner.Activate();
|
||||
trayIcon.Visible = false;
|
||||
}
|
||||
|
||||
public void Minimize()
|
||||
{
|
||||
owner.Hide();
|
||||
trayIcon.Visible = true;
|
||||
}
|
||||
|
||||
public void Dispose() => trayIcon.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace RemSound.App;
|
||||
|
||||
internal static class ManualPeerPrompt
|
||||
{
|
||||
public static string? Show(IWin32Window owner)
|
||||
{
|
||||
using var dialog = new Form
|
||||
{
|
||||
Text = "Add manual peer",
|
||||
StartPosition = FormStartPosition.CenterParent,
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog,
|
||||
MinimizeBox = false,
|
||||
MaximizeBox = false,
|
||||
ShowInTaskbar = false,
|
||||
ClientSize = new Size(440, 130),
|
||||
};
|
||||
// No port hint — RemSound now uses a single canonical UDP port (RemPacket.DefaultPort,
|
||||
// 47830 as of 2026-05-05) for Tailscale, LAN, and relay peers alike. Users just type a
|
||||
// bare IP or hostname; the port is implied. Advanced users can still suffix `:port` for
|
||||
// a non-standard server, but it's no longer the common path that needed onboarding.
|
||||
var textBox = new TextBox
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
Width = 380,
|
||||
AccessibleName = "Peer IP address or hostname",
|
||||
};
|
||||
var okButton = new Button { Text = "OK", AutoSize = true, DialogResult = DialogResult.OK };
|
||||
var cancelButton = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
|
||||
textBox.KeyDown += (_, args) =>
|
||||
{
|
||||
if (args.KeyCode == Keys.Enter)
|
||||
{
|
||||
dialog.DialogResult = DialogResult.OK;
|
||||
dialog.Close();
|
||||
args.Handled = true;
|
||||
args.SuppressKeyPress = true;
|
||||
}
|
||||
};
|
||||
var panel = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(12), RowCount = 3, ColumnCount = 1 };
|
||||
panel.Controls.Add(new Label { Text = "Peer IP address or hostname:", AutoSize = true }, 0, 0);
|
||||
panel.Controls.Add(textBox, 0, 1);
|
||||
var buttons = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.RightToLeft, Dock = DockStyle.Fill };
|
||||
buttons.Controls.Add(okButton);
|
||||
buttons.Controls.Add(cancelButton);
|
||||
panel.Controls.Add(buttons, 0, 2);
|
||||
dialog.Controls.Add(panel);
|
||||
dialog.AcceptButton = okButton;
|
||||
dialog.CancelButton = cancelButton;
|
||||
return dialog.ShowDialog(owner) == DialogResult.OK ? textBox.Text.Trim() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Preferences dialog. Holds the three settings that used to live on the (now-removed)
|
||||
/// Profiles and preferences tab and aren't profile-management actions in their own right:
|
||||
/// * Mute connect/disconnect sounds — the small ding on peer state changes.
|
||||
/// * Accept remote volume commands from peers — opt-in for the remote-control feature.
|
||||
/// * Startup behaviour — opens the existing <see cref="StartupBehaviourDialog"/> sub-dialog.
|
||||
///
|
||||
/// Both checkboxes save through <see cref="RemSoundSettingsStore"/> on every change (so
|
||||
/// the user doesn't need to re-confirm via an OK button). The Startup behaviour button
|
||||
/// just opens the existing modal sub-dialog. Esc or the Close button dismisses.
|
||||
///
|
||||
/// Reachable via the File → Preferences menu item or Ctrl+P from the main window.
|
||||
/// </summary>
|
||||
internal sealed class PreferencesDialog : Form
|
||||
{
|
||||
private readonly Button browseProfilesFolderButton = new()
|
||||
{
|
||||
Text = "&Browse for RemSound profiles folder...",
|
||||
AccessibleName = "Browse for RemSound profiles folder",
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
private readonly AccessibleCheckBox muteCuesBox = new()
|
||||
{
|
||||
Text = "Mute connect/disconnect sounds (Alt+&M)",
|
||||
AccessibleName = "Mute connect/disconnect sounds",
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
private readonly AccessibleCheckBox acceptRemoteVolumeBox = new()
|
||||
{
|
||||
Text = "Accept remote volume commands from peers (Alt+&A)",
|
||||
AccessibleName = "Accept remote volume commands from peers",
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
private readonly Button startupBehaviourButton = new()
|
||||
{
|
||||
Text = "Startup behaviour... (Alt+&S)",
|
||||
AccessibleName = "Startup behaviour",
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
// Update settings — frequency dropdown, manual check button, silent-install checkbox.
|
||||
// Sits above the logging row so users meet it during setup; the canonical order in the
|
||||
// dialog is "things related to the program staying current" before "things related to
|
||||
// diagnosing how it's running".
|
||||
private readonly Label updateFrequencyLabel = new()
|
||||
{
|
||||
Text = "Check for updates (Alt+&U):",
|
||||
AccessibleName = "Check for updates frequency",
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
private readonly ComboBox updateFrequencyBox = new()
|
||||
{
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
Width = 200,
|
||||
AccessibleName = "Check for updates (Alt+U)",
|
||||
};
|
||||
|
||||
private readonly Button checkForUpdatesNowButton = new()
|
||||
{
|
||||
Text = "Check for updates &now",
|
||||
AccessibleName = "Check for updates now",
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
private readonly AccessibleCheckBox silentlyInstallUpdatesBox = new()
|
||||
{
|
||||
Text = "Silently &install updates when available",
|
||||
AccessibleName = "Silently install updates when available",
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
private readonly AccessibleCheckBox loggingBox = new()
|
||||
{
|
||||
Text = "Enable &logs",
|
||||
AccessibleName = "Enable logs",
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
private readonly Button writeLogsNowButton = new()
|
||||
{
|
||||
Text = "&Write logs now",
|
||||
AccessibleName = "Write logs now",
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
private readonly Button closeButton = new()
|
||||
{
|
||||
Text = "Close",
|
||||
AutoSize = true,
|
||||
DialogResult = DialogResult.OK,
|
||||
};
|
||||
|
||||
/// <summary>True if the user toggled Mute cues or Accept remote during this dialog
|
||||
/// session. The owner uses this to know whether to MarkProfileDirty after the dialog
|
||||
/// closes (since both settings live on Profile and need to flag a save-pending state).</summary>
|
||||
public bool ChangedAnyProfileSetting { get; private set; }
|
||||
|
||||
public PreferencesDialog(
|
||||
RemSoundSettingsStore settings,
|
||||
ProfileStore? profileStore,
|
||||
Func<bool> getLoggingEnabled,
|
||||
Action<bool> applyLoggingEnabled,
|
||||
Action writeLogsNow,
|
||||
Action checkForUpdatesNow,
|
||||
Action onUpdateFrequencyChanged)
|
||||
{
|
||||
Text = "Preferences";
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MinimizeBox = false;
|
||||
MaximizeBox = false;
|
||||
ShowInTaskbar = false;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
KeyPreview = true;
|
||||
ClientSize = new Size(560, 440);
|
||||
|
||||
// 1st row — Browse for profiles folder. Same FolderBrowserDialog the startup
|
||||
// ProfileSelectionDialog uses; the choice is persisted to AppConfig.ProfilesDirectory
|
||||
// and applied on next launch (mid-session reload would force a re-pick of profile
|
||||
// which is more disruption than the change is worth — users restart RemSound when
|
||||
// they want to switch folders).
|
||||
browseProfilesFolderButton.Click += (_, _) =>
|
||||
{
|
||||
using var picker = new FolderBrowserDialog
|
||||
{
|
||||
Description = "Choose a folder for RemSound profiles",
|
||||
UseDescriptionForTitle = true,
|
||||
SelectedPath = profileStore?.BaseDirectory ?? AppContext.BaseDirectory,
|
||||
ShowNewFolderButton = true,
|
||||
};
|
||||
if (picker.ShowDialog(this) != DialogResult.OK) return;
|
||||
if (string.IsNullOrWhiteSpace(picker.SelectedPath)) return;
|
||||
var cfg = AppConfig.Load();
|
||||
cfg.ProfilesDirectory = picker.SelectedPath;
|
||||
try
|
||||
{
|
||||
cfg.Save();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, $"Could not save app config: {ex.Message}",
|
||||
"RemSound", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
return;
|
||||
}
|
||||
MessageBox.Show(this,
|
||||
$"Profiles folder updated to:\n\n{picker.SelectedPath}\n\nThe new folder will be used next time RemSound launches.",
|
||||
"Profiles folder updated", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
};
|
||||
|
||||
muteCuesBox.Checked = settings.LoadMuteConnectionCues();
|
||||
muteCuesBox.CheckedChanged += (_, _) =>
|
||||
{
|
||||
settings.SaveMuteConnectionCues(muteCuesBox.Checked);
|
||||
ChangedAnyProfileSetting = true;
|
||||
};
|
||||
|
||||
acceptRemoteVolumeBox.Checked = settings.LoadAcceptRemoteVolumeCommands();
|
||||
acceptRemoteVolumeBox.CheckedChanged += (_, _) =>
|
||||
{
|
||||
settings.SaveAcceptRemoteVolumeCommands(acceptRemoteVolumeBox.Checked);
|
||||
ChangedAnyProfileSetting = true;
|
||||
};
|
||||
|
||||
startupBehaviourButton.Click += (_, _) =>
|
||||
{
|
||||
using var dialog = new StartupBehaviourDialog(profileStore);
|
||||
dialog.ShowDialog(this);
|
||||
// Startup behaviour persists through AppConfig + registry directly, so we
|
||||
// don't need to flag profile-dirty for that.
|
||||
};
|
||||
|
||||
// Update settings — wired against AppConfig directly since they're machine-local.
|
||||
// The frequency combo's index maps 1:1 to the UpdateCheckFrequency enum so reordering
|
||||
// either side stays in lockstep.
|
||||
updateFrequencyBox.Items.AddRange(new object[] { "Never", "Every hour", "Every 6 hours", "Every 24 hours" });
|
||||
var cfgForLoad = AppConfig.Load();
|
||||
updateFrequencyBox.SelectedIndex = (int)cfgForLoad.UpdateCheckFrequency;
|
||||
silentlyInstallUpdatesBox.Checked = cfgForLoad.SilentlyInstallUpdates;
|
||||
updateFrequencyBox.SelectedIndexChanged += (_, _) =>
|
||||
{
|
||||
var cfg = AppConfig.Load();
|
||||
cfg.UpdateCheckFrequency = (UpdateCheckFrequency)updateFrequencyBox.SelectedIndex;
|
||||
try { cfg.Save(); } catch { /* harmless — choice just won't survive a restart */ }
|
||||
onUpdateFrequencyChanged();
|
||||
};
|
||||
silentlyInstallUpdatesBox.CheckedChanged += (_, _) =>
|
||||
{
|
||||
var cfg = AppConfig.Load();
|
||||
cfg.SilentlyInstallUpdates = silentlyInstallUpdatesBox.Checked;
|
||||
try { cfg.Save(); } catch { /* harmless */ }
|
||||
};
|
||||
checkForUpdatesNowButton.Click += (_, _) => checkForUpdatesNow();
|
||||
|
||||
loggingBox.Checked = getLoggingEnabled();
|
||||
loggingBox.CheckedChanged += (_, _) =>
|
||||
{
|
||||
applyLoggingEnabled(loggingBox.Checked);
|
||||
ChangedAnyProfileSetting = true;
|
||||
};
|
||||
|
||||
writeLogsNowButton.Click += (_, _) => writeLogsNow();
|
||||
|
||||
closeButton.Click += (_, _) => Close();
|
||||
|
||||
var panel = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
Padding = new Padding(12),
|
||||
ColumnCount = 1,
|
||||
RowCount = 10,
|
||||
};
|
||||
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
for (var i = 0; i < 9; i++) panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
|
||||
|
||||
// Tab order top-to-bottom: browse, mute cues, accept remote, startup, update
|
||||
// frequency, check-now, silent install, enable logs, write logs now, close. Updates
|
||||
// sit above the log row so a user setting up the app meets them first.
|
||||
browseProfilesFolderButton.TabIndex = 0;
|
||||
muteCuesBox.TabIndex = 1;
|
||||
acceptRemoteVolumeBox.TabIndex = 2;
|
||||
startupBehaviourButton.TabIndex = 3;
|
||||
updateFrequencyBox.TabIndex = 4;
|
||||
checkForUpdatesNowButton.TabIndex = 5;
|
||||
silentlyInstallUpdatesBox.TabIndex = 6;
|
||||
loggingBox.TabIndex = 7;
|
||||
writeLogsNowButton.TabIndex = 8;
|
||||
closeButton.TabIndex = 9;
|
||||
|
||||
// Group the frequency label + combo on one FlowLayoutPanel row so the visible label
|
||||
// sits inline next to the combo while keeping the combo as the focusable target.
|
||||
var freqRow = new FlowLayoutPanel
|
||||
{
|
||||
AutoSize = true,
|
||||
Dock = DockStyle.Fill,
|
||||
FlowDirection = FlowDirection.LeftToRight,
|
||||
WrapContents = false,
|
||||
Padding = new Padding(0, 4, 0, 0),
|
||||
};
|
||||
updateFrequencyLabel.Padding = new Padding(0, 6, 8, 0);
|
||||
freqRow.Controls.Add(updateFrequencyLabel);
|
||||
freqRow.Controls.Add(updateFrequencyBox);
|
||||
|
||||
panel.Controls.Add(browseProfilesFolderButton, 0, 0);
|
||||
panel.Controls.Add(muteCuesBox, 0, 1);
|
||||
panel.Controls.Add(acceptRemoteVolumeBox, 0, 2);
|
||||
panel.Controls.Add(startupBehaviourButton, 0, 3);
|
||||
panel.Controls.Add(freqRow, 0, 4);
|
||||
panel.Controls.Add(checkForUpdatesNowButton, 0, 5);
|
||||
panel.Controls.Add(silentlyInstallUpdatesBox, 0, 6);
|
||||
panel.Controls.Add(loggingBox, 0, 7);
|
||||
panel.Controls.Add(writeLogsNowButton, 0, 8);
|
||||
|
||||
var buttons = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Bottom,
|
||||
FlowDirection = FlowDirection.RightToLeft,
|
||||
AutoSize = true,
|
||||
Padding = new Padding(0, 0, 12, 12),
|
||||
};
|
||||
buttons.Controls.Add(closeButton);
|
||||
|
||||
Controls.Add(panel);
|
||||
Controls.Add(buttons);
|
||||
|
||||
AcceptButton = closeButton;
|
||||
CancelButton = closeButton;
|
||||
|
||||
KeyDown += (_, e) =>
|
||||
{
|
||||
if (e.KeyCode == Keys.Escape)
|
||||
{
|
||||
Close();
|
||||
e.SuppressKeyPress = true;
|
||||
e.Handled = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>Tiny single-line modal — "give this profile a name". Used by File → Rename
|
||||
/// (and historically by File → Save As, before that flow moved to a real Windows
|
||||
/// SaveFileDialog on 2026-05-10). Returns the trimmed name or null on cancel.
|
||||
///
|
||||
/// Two parameter knobs let the dialog title and prompt label change between use cases:
|
||||
/// * Rename: title = "Rename profile", prompt = "Please enter a new name for your profile:"
|
||||
/// * Legacy save-as (close-confirm path): title = "Save profile as", prompt = "Profile name:"
|
||||
/// and pass <paramref name="store"/> non-null so the dialog refuses an existing-name unless
|
||||
/// the user confirms overwrite.
|
||||
///
|
||||
/// When <paramref name="store"/> is null, no overwrite check is performed — the caller is
|
||||
/// responsible for handling name collisions (rename has its own conflict logic in MainForm).</summary>
|
||||
internal static class ProfileSaveAsPrompt
|
||||
{
|
||||
public static string? Show(
|
||||
IWin32Window owner,
|
||||
ProfileStore? store,
|
||||
string? defaultName = null,
|
||||
string dialogTitle = "Save profile as",
|
||||
string promptLabel = "Profile name:")
|
||||
{
|
||||
using var dialog = new Form
|
||||
{
|
||||
Text = dialogTitle,
|
||||
StartPosition = FormStartPosition.CenterParent,
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog,
|
||||
MinimizeBox = false,
|
||||
MaximizeBox = false,
|
||||
ShowInTaskbar = false,
|
||||
ClientSize = new Size(420, 140),
|
||||
};
|
||||
var textBox = new TextBox
|
||||
{
|
||||
Width = 380,
|
||||
Text = defaultName ?? "",
|
||||
AccessibleName = promptLabel.TrimEnd(':', ' '),
|
||||
};
|
||||
var okButton = new Button { Text = "&OK", AutoSize = true, DialogResult = DialogResult.OK };
|
||||
var cancelButton = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
|
||||
textBox.KeyDown += (_, args) =>
|
||||
{
|
||||
if (args.KeyCode == Keys.Enter)
|
||||
{
|
||||
dialog.DialogResult = DialogResult.OK;
|
||||
dialog.Close();
|
||||
args.Handled = true;
|
||||
args.SuppressKeyPress = true;
|
||||
}
|
||||
};
|
||||
var panel = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
Padding = new Padding(12),
|
||||
RowCount = 3,
|
||||
ColumnCount = 1,
|
||||
};
|
||||
panel.Controls.Add(new Label { Text = promptLabel, AutoSize = true }, 0, 0);
|
||||
panel.Controls.Add(textBox, 0, 1);
|
||||
var buttons = new FlowLayoutPanel
|
||||
{
|
||||
AutoSize = true,
|
||||
FlowDirection = FlowDirection.RightToLeft,
|
||||
Dock = DockStyle.Fill,
|
||||
};
|
||||
buttons.Controls.Add(okButton);
|
||||
buttons.Controls.Add(cancelButton);
|
||||
panel.Controls.Add(buttons, 0, 2);
|
||||
dialog.Controls.Add(panel);
|
||||
dialog.AcceptButton = okButton;
|
||||
dialog.CancelButton = cancelButton;
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (dialog.ShowDialog(owner) != DialogResult.OK) return null;
|
||||
var name = textBox.Text.Trim();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
MessageBox.Show(owner, "Please enter a profile name.", "RemSound", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
continue;
|
||||
}
|
||||
if (store is not null && store.Exists(name))
|
||||
{
|
||||
var overwrite = MessageBox.Show(owner,
|
||||
$"A profile named \"{name}\" already exists. Overwrite?",
|
||||
"Confirm overwrite", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2);
|
||||
if (overwrite != DialogResult.Yes) continue;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Modal dialog shown at app startup to pick which profile to load. Listbox of saved
|
||||
/// profile titles plus a synthetic "(Blank template)" entry for an unsaved-defaults
|
||||
/// session. Enter or OK selects; Esc does nothing (deliberately disabled — picking is
|
||||
/// required); Alt+F4 closes the dialog and exits the app; Del on a profile prompts to
|
||||
/// delete it with a yes/no confirm. The user can also browse to a custom profiles
|
||||
/// folder, which persists in <c>remsound.config.json</c> next to the exe.
|
||||
///
|
||||
/// On OK, exposes:
|
||||
/// * <see cref="SelectedTitle"/> — the chosen title, or null for blank template.
|
||||
/// * <see cref="SelectedProfile"/> — the loaded <see cref="Profile"/>, or null for blank.
|
||||
/// * <see cref="Store"/> — the (possibly-rebuilt) profile store. If the user clicked
|
||||
/// Browse and changed the folder, this points at the new folder; the caller should
|
||||
/// use this reference rather than the one it passed in.
|
||||
/// </summary>
|
||||
internal sealed class ProfileSelectionDialog : Form
|
||||
{
|
||||
private const string BlankTemplateLabel = "(Blank template)";
|
||||
|
||||
private ProfileStore store;
|
||||
private readonly ListBox listBox;
|
||||
private readonly Label folderLabel;
|
||||
|
||||
public string? SelectedTitle { get; private set; }
|
||||
public Profile? SelectedProfile { get; private set; }
|
||||
/// <summary>Current profile store. If the user clicked Browse during the dialog,
|
||||
/// this is rebuilt to point at the new folder; otherwise it's the same instance the
|
||||
/// caller passed in.</summary>
|
||||
public ProfileStore Store => store;
|
||||
|
||||
public ProfileSelectionDialog(ProfileStore store)
|
||||
{
|
||||
this.store = store;
|
||||
|
||||
Text = "RemSound — pick a profile";
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MinimizeBox = false;
|
||||
MaximizeBox = false;
|
||||
ShowInTaskbar = true;
|
||||
ClientSize = new Size(480, 420);
|
||||
// Esc is deliberately ignored (no CancelButton). Alt+F4 routes through the
|
||||
// window manager to FormClosing → DialogResult.Cancel, which the caller treats
|
||||
// as "user wants to quit".
|
||||
KeyPreview = true;
|
||||
|
||||
listBox = new ListBox
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
IntegralHeight = false,
|
||||
AccessibleName = "Profiles",
|
||||
};
|
||||
listBox.KeyDown += OnListKeyDown;
|
||||
listBox.DoubleClick += (_, _) => Accept();
|
||||
|
||||
var instructions = new Label
|
||||
{
|
||||
Text = "Select a profile and press Enter, or pick \"" + BlankTemplateLabel + "\" to start fresh.",
|
||||
Dock = DockStyle.Top,
|
||||
AutoSize = false,
|
||||
Height = 36,
|
||||
Padding = new Padding(8, 8, 8, 4),
|
||||
};
|
||||
|
||||
// Folder status + Browse button row at the bottom. Browse opens a folder picker;
|
||||
// on OK we save AppConfig, rebuild the store, and refresh the list. Status label
|
||||
// shows the active folder so the user can verify where their profiles are coming
|
||||
// from. NVDA reads the label as a sibling of the listbox.
|
||||
folderLabel = new Label
|
||||
{
|
||||
Text = "Profiles folder: " + store.BaseDirectory,
|
||||
Dock = DockStyle.Top,
|
||||
AutoSize = false,
|
||||
Height = 28,
|
||||
Padding = new Padding(8, 4, 8, 4),
|
||||
AccessibleName = "Profiles folder: " + store.BaseDirectory,
|
||||
};
|
||||
|
||||
var okButton = new Button { Text = "&OK", AutoSize = true, DialogResult = DialogResult.None };
|
||||
okButton.Click += (_, _) => Accept();
|
||||
var deleteButton = new Button { Text = "&Delete", AutoSize = true };
|
||||
deleteButton.Click += (_, _) => DeleteSelected();
|
||||
var browseButton = new Button { Text = "&Browse for profiles folder…", AutoSize = true };
|
||||
browseButton.Click += (_, _) => BrowseForFolder();
|
||||
var resetFolderButton = new Button { Text = "&Reset to default folder", AutoSize = true };
|
||||
resetFolderButton.Click += (_, _) => ResetToDefaultFolder();
|
||||
var buttonRow = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Bottom,
|
||||
FlowDirection = FlowDirection.LeftToRight,
|
||||
Height = 80,
|
||||
AutoSize = false,
|
||||
Padding = new Padding(8),
|
||||
WrapContents = true,
|
||||
};
|
||||
buttonRow.Controls.Add(okButton);
|
||||
buttonRow.Controls.Add(deleteButton);
|
||||
buttonRow.Controls.Add(browseButton);
|
||||
buttonRow.Controls.Add(resetFolderButton);
|
||||
|
||||
Controls.Add(listBox);
|
||||
Controls.Add(buttonRow);
|
||||
Controls.Add(folderLabel);
|
||||
Controls.Add(instructions);
|
||||
|
||||
AcceptButton = okButton; // makes Enter work in the form context too
|
||||
|
||||
Load += (_, _) =>
|
||||
{
|
||||
RefreshList();
|
||||
listBox.Focus();
|
||||
};
|
||||
}
|
||||
|
||||
private void RefreshList()
|
||||
{
|
||||
var prevSelected = listBox.SelectedItem as string;
|
||||
listBox.BeginUpdate();
|
||||
listBox.Items.Clear();
|
||||
listBox.Items.Add(BlankTemplateLabel);
|
||||
foreach (var t in store.ListProfileTitles())
|
||||
{
|
||||
listBox.Items.Add(t);
|
||||
}
|
||||
// Try to restore selection; fall back to first item.
|
||||
var idx = prevSelected is null ? 0 : Math.Max(0, listBox.Items.IndexOf(prevSelected));
|
||||
listBox.SelectedIndex = Math.Min(idx, listBox.Items.Count - 1);
|
||||
listBox.EndUpdate();
|
||||
// Keep the folder label in sync so it always reflects what the listbox is reading.
|
||||
folderLabel.Text = "Profiles folder: " + store.BaseDirectory;
|
||||
folderLabel.AccessibleName = folderLabel.Text;
|
||||
}
|
||||
|
||||
private void OnListKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.KeyCode == Keys.Enter)
|
||||
{
|
||||
Accept();
|
||||
e.Handled = true;
|
||||
e.SuppressKeyPress = true;
|
||||
}
|
||||
else if (e.KeyCode == Keys.Delete)
|
||||
{
|
||||
DeleteSelected();
|
||||
e.Handled = true;
|
||||
e.SuppressKeyPress = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void Accept()
|
||||
{
|
||||
var selected = listBox.SelectedItem as string;
|
||||
if (string.IsNullOrEmpty(selected)) return;
|
||||
if (selected == BlankTemplateLabel)
|
||||
{
|
||||
SelectedTitle = null;
|
||||
SelectedProfile = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedTitle = selected;
|
||||
SelectedProfile = store.Load(selected);
|
||||
if (SelectedProfile is null)
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
$"Could not read profile \"{selected}\". Treating as blank template.",
|
||||
"RemSound", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
SelectedTitle = null;
|
||||
}
|
||||
}
|
||||
DialogResult = DialogResult.OK;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void DeleteSelected()
|
||||
{
|
||||
var selected = listBox.SelectedItem as string;
|
||||
if (string.IsNullOrEmpty(selected) || selected == BlankTemplateLabel) return;
|
||||
var result = MessageBox.Show(this,
|
||||
$"Delete profile \"{selected}\"? This cannot be undone.",
|
||||
"Confirm delete", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2);
|
||||
if (result != DialogResult.Yes) return;
|
||||
if (!store.Delete(selected))
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
$"Could not delete \"{selected}\".",
|
||||
"RemSound", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
RefreshList();
|
||||
}
|
||||
|
||||
/// <summary>Open a folder picker, persist the choice to AppConfig, and rebuild the
|
||||
/// profile store + list against the new folder. No-op on cancel. If the new folder
|
||||
/// has no profiles yet, the listbox simply shows just the blank-template entry; the
|
||||
/// user can save into the new folder later.</summary>
|
||||
private void BrowseForFolder()
|
||||
{
|
||||
using var picker = new FolderBrowserDialog
|
||||
{
|
||||
Description = "Choose a folder for RemSound profiles",
|
||||
UseDescriptionForTitle = true,
|
||||
SelectedPath = store.BaseDirectory,
|
||||
ShowNewFolderButton = true,
|
||||
};
|
||||
if (picker.ShowDialog(this) != DialogResult.OK) return;
|
||||
ApplyFolder(picker.SelectedPath);
|
||||
}
|
||||
|
||||
private void ResetToDefaultFolder()
|
||||
{
|
||||
// Clearing the AppConfig field and reloading swings the store back to the legacy
|
||||
// default (per-machine subfolder under the exe). Cheap and reversible — user can
|
||||
// Browse to a custom folder again any time.
|
||||
var cfg = AppConfig.Load();
|
||||
cfg.ProfilesDirectory = null;
|
||||
try { cfg.Save(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, $"Could not save app config: {ex.Message}",
|
||||
"RemSound", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
return;
|
||||
}
|
||||
store = cfg.CreateStore();
|
||||
RefreshList();
|
||||
}
|
||||
|
||||
private void ApplyFolder(string folderPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(folderPath)) return;
|
||||
var cfg = AppConfig.Load();
|
||||
cfg.ProfilesDirectory = folderPath;
|
||||
try { cfg.Save(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, $"Could not save app config: {ex.Message}",
|
||||
"RemSound", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
return;
|
||||
}
|
||||
store = cfg.CreateStore();
|
||||
RefreshList();
|
||||
}
|
||||
|
||||
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
|
||||
{
|
||||
// Eat plain Esc — selection is required, no implicit cancel.
|
||||
if (keyData == Keys.Escape) return true;
|
||||
return base.ProcessCmdKey(ref msg, keyData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System.Runtime;
|
||||
using System.Windows.Forms;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
private static void Main()
|
||||
{
|
||||
// SustainedLowLatency tells the GC to avoid full (gen 2) collections while audio is streaming.
|
||||
// Gen 0/1 collections still happen but are sub-millisecond; the long pauses that were causing
|
||||
// the receiver to fall behind in clusters of 4-5 underruns at a time were almost certainly
|
||||
// gen 2 sweeps. This trades a bit of memory headroom (the GC will hold on to garbage longer)
|
||||
// for dramatically more predictable timing — exactly the trade real-time audio wants.
|
||||
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;
|
||||
|
||||
ApplicationConfiguration.Initialize();
|
||||
|
||||
// F1 anywhere = open the bundled manual. Installed *before* the first ShowDialog so
|
||||
// it works on the profile picker (the very first thing the user sees). The filter
|
||||
// is per-thread and modifier-aware: bare F1 only, so Shift/Ctrl/Alt+F1 stay free.
|
||||
HelpLauncher.Install();
|
||||
|
||||
// Outer loop: lets ProfileManagementDialog change the profiles folder mid-session.
|
||||
// When that happens, MainForm sets ReloadFromScratch=true, we re-read AppConfig, build
|
||||
// a fresh ProfileStore, and re-show ProfileSelectionDialog so the user picks a profile
|
||||
// (or blank template) from the *new* folder. Inner loop handles the cheaper "switch to
|
||||
// a profile in the same folder" case.
|
||||
while (true)
|
||||
{
|
||||
var appConfig = AppConfig.Load();
|
||||
var store = appConfig.CreateStore();
|
||||
|
||||
Profile? profile;
|
||||
string? title;
|
||||
// Auto-load shortcut: if AppConfig.StartWithProfileTitle is set and the named
|
||||
// profile actually exists in the current store, skip the picker entirely and
|
||||
// load that profile directly. This is what the Startup behaviour dialog's
|
||||
// "Start with a specific profile" toggle drives. Combined with the Windows
|
||||
// auto-start registry entry and the StartMinimised flag, it lets the user
|
||||
// boot a machine and have RemSound up and streaming with no clicks. Falls
|
||||
// through to the normal picker if the configured profile no longer exists
|
||||
// (deleted since it was selected, or the profiles folder changed) so the user
|
||||
// isn't stuck.
|
||||
Profile? autoLoaded = null;
|
||||
string? autoLoadedTitle = null;
|
||||
if (!string.IsNullOrWhiteSpace(appConfig.StartWithProfileTitle))
|
||||
{
|
||||
try
|
||||
{
|
||||
autoLoaded = store.Load(appConfig.StartWithProfileTitle!);
|
||||
if (autoLoaded is not null) autoLoadedTitle = appConfig.StartWithProfileTitle;
|
||||
}
|
||||
catch { /* fall back to picker */ }
|
||||
}
|
||||
|
||||
if (autoLoaded is not null)
|
||||
{
|
||||
profile = autoLoaded;
|
||||
title = autoLoadedTitle;
|
||||
}
|
||||
else
|
||||
{
|
||||
using var dialog = new ProfileSelectionDialog(store);
|
||||
if (dialog.ShowDialog() != DialogResult.OK) return;
|
||||
// ProfileSelectionDialog can have changed the folder via its Browse button;
|
||||
// if so, it's already saved AppConfig and rebuilt its internal store. Pick up
|
||||
// its post-Browse store reference for the rest of the session.
|
||||
store = dialog.Store;
|
||||
profile = dialog.SelectedProfile;
|
||||
title = dialog.SelectedTitle;
|
||||
}
|
||||
|
||||
// Switch-profile loop: when the user clicks "Switch to profile" in the Manage
|
||||
// Profiles dialog, the form sets NextProfileTitleToLoad and closes; we re-open
|
||||
// MainForm under the newly chosen profile. Null = user closed the form normally
|
||||
// → exit. ReloadFromScratch = the user changed the profiles FOLDER mid-session,
|
||||
// so we break out of this inner loop and let the outer loop redo the selection
|
||||
// dialog under the new folder.
|
||||
var reloadFromScratch = false;
|
||||
string? nextPath = null;
|
||||
while (true)
|
||||
{
|
||||
using var form = new MainForm(store, profile, title, nextPath);
|
||||
Application.Run(form);
|
||||
|
||||
if (form.ReloadFromScratch)
|
||||
{
|
||||
reloadFromScratch = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Path-based reload (File → Open profile from a path that may be outside
|
||||
// the active store's BaseDirectory) takes precedence — read JSON directly
|
||||
// from that path. Falls back to title-based store.Load when no path is set
|
||||
// (e.g. legacy switch-by-title flows that pre-date the path tracking).
|
||||
nextPath = form.NextProfilePathToLoad;
|
||||
var nextTitle = form.NextProfileTitleToLoad;
|
||||
if (!string.IsNullOrEmpty(nextPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(nextPath);
|
||||
profile = System.Text.Json.JsonSerializer.Deserialize<Profile>(json) ?? Profile.NewBlank();
|
||||
title = !string.IsNullOrEmpty(nextTitle)
|
||||
? nextTitle
|
||||
: Path.GetFileNameWithoutExtension(nextPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Malformed / unreadable JSON. Fall back to blank template under
|
||||
// whatever title we have, rather than crashing the loop.
|
||||
profile = Profile.NewBlank();
|
||||
title = !string.IsNullOrEmpty(nextTitle)
|
||||
? nextTitle
|
||||
: Path.GetFileNameWithoutExtension(nextPath);
|
||||
nextPath = null;
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(nextTitle))
|
||||
{
|
||||
title = nextTitle;
|
||||
profile = store.Load(nextTitle) ?? Profile.NewBlank();
|
||||
}
|
||||
else
|
||||
{
|
||||
return; // form closed normally — exit app
|
||||
}
|
||||
}
|
||||
|
||||
if (!reloadFromScratch) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<RootNamespace>RemSound.App</RootNamespace>
|
||||
<AssemblyName>RemSound</AssemblyName>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<ApplicationHighDpiMode>SystemAware</ApplicationHighDpiMode>
|
||||
<!-- Release version. The self-updater (RemSoundUpdater) compares this against the
|
||||
tag_name on the latest GitHub release; bump it on every public release. The
|
||||
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
|
||||
is what the About dialog and the updater both read. -->
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\RemSound.Core\RemSound.Core.csproj" />
|
||||
<ProjectReference Include="..\RemSound.Sender\RemSound.Sender.csproj" />
|
||||
<ProjectReference Include="..\RemSound.Receiver\RemSound.Receiver.csproj" />
|
||||
<PackageReference Include="NAudio" Version="2.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Peer-state notification sounds. Copied next to RemSound.exe so SoundPlayer can find
|
||||
them via AppContext.BaseDirectory. -->
|
||||
<Content Include="..\..\connect.wav">
|
||||
<Link>connect.wav</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="..\..\disconnect.wav">
|
||||
<Link>disconnect.wav</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<!-- User manual. F1 anywhere in the app opens this via the user's default browser
|
||||
(HelpLauncher.OpenManual). The PreserveNewest mode means a fresh publish overwrites
|
||||
the published copy whenever the source is newer; manually-edited copies inside
|
||||
publish/ get clobbered, which is correct (publish is build output, not config). -->
|
||||
<Content Include="..\..\readme.html">
|
||||
<Link>readme.html</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,184 @@
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight tab-separated log file written next to the executable in <c>logs\</c>.
|
||||
/// Two row kinds:
|
||||
/// SNAP — periodic snapshot of runtime counters (one per second)
|
||||
/// EVT — one-off events (connect, disconnect, codec change, errors)
|
||||
/// Format:
|
||||
/// SNAP\t{Timestamp}\t{Machine}\t{Connected}\t{SendRunning}\t{ReceiveRunning}\t{Codec}\t
|
||||
/// {MaxLatencyMs}\t{TargetLatencyMs}\t{BufferMs}\t{SenderPackets}\t{SenderKB}\t
|
||||
/// {SenderDevice}\t{ReceiverPackets}\t{ReceiverKB}\t{Underruns}\t{Drops}\t{ReceiveDevice}
|
||||
/// EVT\t{Timestamp}\t{Message}
|
||||
///
|
||||
/// The log file is created lazily on the first write that arrives while <see cref="Enabled"/>
|
||||
/// is true. When the user has logs turned off in Preferences (the default), no file is created
|
||||
/// at all — the App can construct the log object freely without spawning empty files in
|
||||
/// <c>logs\</c>. Flipping <see cref="Enabled"/> back on mid-session is also safe: the next
|
||||
/// write creates the file with its header and the session continues normally.
|
||||
/// </summary>
|
||||
internal sealed class RemSoundLog : IDisposable
|
||||
{
|
||||
// MaxLatencyMsAsio / TargetLatencyMsAsio columns hold the per-lane numbers in
|
||||
// BothIndependent mode. In WasapiOnly they are 0 and the legacy MaxLatencyMs /
|
||||
// TargetLatencyMs columns continue to mean "the receiver's only latency".
|
||||
private const string SnapHeader =
|
||||
"Kind\tTimestamp\tMachine\tConnected\tSendRunning\tReceiveRunning\tCodec\t" +
|
||||
"MaxLatencyMs\tTargetLatencyMs\tBufferMs\tSenderPackets\tSenderKB\t" +
|
||||
"SenderDevice\tReceiverPackets\tReceiverKB\tUnderruns\tDrops\tReceiveDevice\tHeartbeat\t" +
|
||||
"OpusFecRecoveries\tOpusUnrecoveredGaps\tMaxLatencyMsAsio\tTargetLatencyMsAsio";
|
||||
|
||||
private StreamWriter? writer;
|
||||
private bool fileCreationFailed;
|
||||
/// <summary>Serialises all writes. StreamWriter is documented as non-thread-safe and
|
||||
/// concurrent WriteLine calls from the mix loop, heartbeat thread, network listener,
|
||||
/// UI thread and ASIO callback can interleave bytes into a single line in the output
|
||||
/// file. Worse, an interleaved write can leave the StreamWriter's internal char buffer
|
||||
/// in a state that throws on the next Flush — that exception escapes the inner try/catch
|
||||
/// and can bring the process down. A single gate around every WriteLine, every Dispose
|
||||
/// and every lazy-init step serialises writes cleanly; the cost is microseconds and
|
||||
/// worth the diagnostic integrity.</summary>
|
||||
private readonly object writeGate = new();
|
||||
|
||||
/// <summary>Path of the log file once it has been created. Null until the first write
|
||||
/// arrives with <see cref="Enabled"/> true (or null forever if logging is never enabled
|
||||
/// or file creation fails).</summary>
|
||||
public string? Path { get; private set; }
|
||||
|
||||
/// <summary>Master gate for all writes. When false, both <see cref="Event"/> and
|
||||
/// <see cref="Snapshot"/> short-circuit before touching the file system — no creation,
|
||||
/// no headers, no data. Defaults to false so the App can construct the log object
|
||||
/// before reading the user's preference; the App pushes the real value in after.</summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public RemSoundLog()
|
||||
{
|
||||
// No file work in the constructor. EnsureFileOpenLocked does it lazily on first
|
||||
// write, only when Enabled has been confirmed true.
|
||||
}
|
||||
|
||||
/// <summary>Open the underlying file if it hasn't been opened yet and write the schema
|
||||
/// header + a "log started" event line. Must be called while holding
|
||||
/// <see cref="writeGate"/>. Returns true on success or if the file is already open;
|
||||
/// false if creation has failed (either now or earlier) — caller should give up on the
|
||||
/// current write.</summary>
|
||||
private bool EnsureFileOpenLocked()
|
||||
{
|
||||
if (writer is not null) return true;
|
||||
if (fileCreationFailed) return false;
|
||||
try
|
||||
{
|
||||
var dir = System.IO.Path.Combine(AppContext.BaseDirectory, "logs");
|
||||
Directory.CreateDirectory(dir);
|
||||
var name = $"RemSound-{Sanitize(Environment.MachineName)}-{Environment.ProcessId}-{DateTime.Now:yyyyMMdd-HHmmss}.log";
|
||||
Path = System.IO.Path.Combine(dir, name);
|
||||
writer = new StreamWriter(new FileStream(Path, FileMode.CreateNew, FileAccess.Write, FileShare.ReadWrite))
|
||||
{
|
||||
AutoFlush = true,
|
||||
};
|
||||
writer.WriteLine(SnapHeader);
|
||||
writer.WriteLine($"EVT\t{DateTime.Now:o}\tlog started");
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Logging is best-effort. Locked dir, permissions, disk full — any of these
|
||||
// shouldn't kill the app. Park the file as failed so we don't keep retrying
|
||||
// creation on every subsequent write attempt.
|
||||
writer = null;
|
||||
Path = null;
|
||||
fileCreationFailed = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Snapshot(
|
||||
bool connected,
|
||||
bool sendRunning,
|
||||
bool receiveRunning,
|
||||
string codec,
|
||||
int maxLatencyMs,
|
||||
int targetLatencyMs,
|
||||
int bufferMs,
|
||||
long senderPackets,
|
||||
long senderBytes,
|
||||
string senderDevice,
|
||||
long receiverPackets,
|
||||
long receiverBytes,
|
||||
long underruns,
|
||||
long drops,
|
||||
string receiveDevice,
|
||||
string heartbeat,
|
||||
long opusFecRecoveries,
|
||||
long opusUnrecoveredGaps,
|
||||
int maxLatencyMsAsio = 0,
|
||||
int targetLatencyMsAsio = 0)
|
||||
{
|
||||
if (!Enabled) return;
|
||||
lock (writeGate)
|
||||
{
|
||||
if (!EnsureFileOpenLocked()) return;
|
||||
try
|
||||
{
|
||||
writer!.WriteLine(string.Join('\t',
|
||||
"SNAP",
|
||||
DateTime.Now.ToString("o"),
|
||||
Environment.MachineName,
|
||||
connected,
|
||||
sendRunning,
|
||||
receiveRunning,
|
||||
codec,
|
||||
maxLatencyMs,
|
||||
targetLatencyMs,
|
||||
bufferMs,
|
||||
senderPackets,
|
||||
senderBytes / 1024,
|
||||
Sanitize(senderDevice),
|
||||
receiverPackets,
|
||||
receiverBytes / 1024,
|
||||
underruns,
|
||||
drops,
|
||||
Sanitize(receiveDevice),
|
||||
Sanitize(heartbeat),
|
||||
opusFecRecoveries,
|
||||
opusUnrecoveredGaps,
|
||||
maxLatencyMsAsio,
|
||||
targetLatencyMsAsio));
|
||||
}
|
||||
catch { /* swallow — log is best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
public void Event(string message)
|
||||
{
|
||||
if (!Enabled) return;
|
||||
lock (writeGate)
|
||||
{
|
||||
if (!EnsureFileOpenLocked()) return;
|
||||
try
|
||||
{
|
||||
writer!.WriteLine($"EVT\t{DateTime.Now:o}\t{message.Replace('\t', ' ').Replace('\n', ' ')}");
|
||||
}
|
||||
catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (writeGate)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (writer is not null && Enabled)
|
||||
{
|
||||
// Inline the "log stopped" write so we don't re-acquire the gate.
|
||||
writer.WriteLine($"EVT\t{DateTime.Now:o}\tlog stopped");
|
||||
}
|
||||
writer?.Dispose();
|
||||
}
|
||||
catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static string Sanitize(string value) => value.Replace('\t', ' ').Replace('\n', ' ');
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Self-updater. Polls the GitHub Releases API for the latest published version, compares it
|
||||
/// to the running assembly's version, and (optionally) downloads and installs the new build.
|
||||
///
|
||||
/// Update install flow (Windows-only): RemSound.exe can't overwrite itself while it's running,
|
||||
/// so a successful install does the swap via a detached <c>cmd.exe</c> helper:
|
||||
/// <list type="number">
|
||||
/// <item>Download the release ZIP to <c>%TEMP%\RemSound-update-<tag>.zip</c>.</item>
|
||||
/// <item>Extract to <c><exe>\_update\</c>.</item>
|
||||
/// <item>Write a one-shot batch file at <c><exe>\_apply-update.cmd</c> that waits for
|
||||
/// RemSound.exe to exit, robocopies the staged folder over the publish folder, deletes
|
||||
/// the staging area, restarts RemSound.exe, and removes itself.</item>
|
||||
/// <item>Start the batch with <c>CreateNoWindow</c> + detached, then call
|
||||
/// <see cref="Application.Exit"/>.</item>
|
||||
/// </list>
|
||||
/// The batch survives RemSound's exit because <c>cmd.exe</c> is its own process. Robocopy's
|
||||
/// retry/wait flags handle the brief moment between RemSound exit and the file unlock.
|
||||
///
|
||||
/// The GitHub repo to poll is hard-coded — the App was designed to be redistributed from a
|
||||
/// single canonical release stream, not to be re-pointed at a fork. If you need to publish
|
||||
/// from a different repo, change <see cref="RepoOwner"/> / <see cref="RepoName"/>.
|
||||
/// </summary>
|
||||
internal sealed class RemSoundUpdater : IDisposable
|
||||
{
|
||||
public const string RepoOwner = "Ednunp";
|
||||
public const string RepoName = "RemSound";
|
||||
|
||||
/// <summary>Asset name on the GitHub release that the updater downloads. The release
|
||||
/// publisher's <c>gh release create</c> command must attach exactly this filename for
|
||||
/// the auto-install path to work; other assets in the release are ignored. The literal
|
||||
/// "{tag}" placeholder is replaced with the release's <c>tag_name</c> at runtime.</summary>
|
||||
public const string AssetNameTemplate = "RemSound-{tag}.zip";
|
||||
|
||||
private static readonly HttpClient http = CreateClient();
|
||||
|
||||
/// <summary>Sink for diagnostic lines — the App wires this to <c>logFile.Event</c> so an
|
||||
/// admin can see what the updater did (which version it saw, whether it downloaded, why
|
||||
/// an install attempt failed). Updater output never goes to a popup unless the user
|
||||
/// triggered a manual check.</summary>
|
||||
public Action<string>? Log { get; set; }
|
||||
|
||||
public string CurrentVersion => Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// HttpClient is static and shared across the process; nothing to dispose here.
|
||||
}
|
||||
|
||||
/// <summary>Hit the GitHub Releases API, parse the latest release, return a struct
|
||||
/// describing what was found. Returns null if the request fails (network down, rate
|
||||
/// limited, repo not found) or if the latest version is not newer than the running
|
||||
/// assembly. Caller decides whether to surface "you're up to date" vs silently doing
|
||||
/// nothing — both paths get null back.</summary>
|
||||
public async Task<UpdateInfo?> CheckForUpdateAsync(CancellationToken token = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"https://api.github.com/repos/{RepoOwner}/{RepoName}/releases/latest";
|
||||
Log?.Invoke($"updater: GET {url}");
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
|
||||
using var resp = await http.SendAsync(req, token).ConfigureAwait(false);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
Log?.Invoke($"updater: HTTP {(int)resp.StatusCode} from GitHub");
|
||||
return null;
|
||||
}
|
||||
await using var stream = await resp.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
|
||||
var release = await JsonSerializer.DeserializeAsync<GitHubRelease>(stream, JsonOpts, token).ConfigureAwait(false);
|
||||
if (release?.TagName is null)
|
||||
{
|
||||
Log?.Invoke("updater: response had no tag_name");
|
||||
return null;
|
||||
}
|
||||
|
||||
var latest = ParseTag(release.TagName);
|
||||
var current = Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0);
|
||||
Log?.Invoke($"updater: current={current.ToString(3)} latest={latest.ToString(3)} ({release.TagName})");
|
||||
if (latest <= current) return null;
|
||||
|
||||
var expectedAsset = AssetNameTemplate.Replace("{tag}", release.TagName);
|
||||
var asset = release.Assets?.FirstOrDefault(a => string.Equals(a.Name, expectedAsset, StringComparison.OrdinalIgnoreCase));
|
||||
if (asset?.BrowserDownloadUrl is null)
|
||||
{
|
||||
Log?.Invoke($"updater: latest release has no asset named '{expectedAsset}'");
|
||||
return null;
|
||||
}
|
||||
|
||||
return new UpdateInfo(
|
||||
Tag: release.TagName,
|
||||
Version: latest,
|
||||
DownloadUrl: asset.BrowserDownloadUrl,
|
||||
ReleaseNotes: release.Body ?? "",
|
||||
ReleaseUrl: release.HtmlUrl ?? "");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.Invoke($"updater: check failed: {ex.GetType().Name}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Download the update ZIP, stage it next to RemSound.exe, spawn the detached
|
||||
/// install helper, and ask the App to exit so the helper can take over. Returns true if
|
||||
/// the helper was launched (caller should Application.Exit immediately afterwards);
|
||||
/// false on any failure earlier in the pipeline. A false return leaves the running
|
||||
/// instance untouched.</summary>
|
||||
public async Task<bool> DownloadAndStageInstallAsync(UpdateInfo info, CancellationToken token = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
var stagingDir = Path.Combine(baseDir, "_update");
|
||||
var zipPath = Path.Combine(Path.GetTempPath(), $"RemSound-update-{info.Tag}.zip");
|
||||
var batchPath = Path.Combine(baseDir, "_apply-update.cmd");
|
||||
|
||||
// Tidy any leftover from a previous failed attempt before we start.
|
||||
TryDelete(zipPath);
|
||||
TryDeleteDirectory(stagingDir);
|
||||
|
||||
Log?.Invoke($"updater: downloading {info.DownloadUrl}");
|
||||
await using (var src = await http.GetStreamAsync(info.DownloadUrl, token).ConfigureAwait(false))
|
||||
await using (var dst = File.Create(zipPath))
|
||||
{
|
||||
await src.CopyToAsync(dst, token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Log?.Invoke($"updater: extracting to {stagingDir}");
|
||||
Directory.CreateDirectory(stagingDir);
|
||||
System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, stagingDir, overwriteFiles: true);
|
||||
|
||||
// Some release zips wrap everything in a single top-level folder
|
||||
// (e.g. "RemSound-v1.1/RemSound.exe"). Flatten if that's the case so the
|
||||
// robocopy step copies the right tree over the install location.
|
||||
var stagingRoot = ResolveStagingRoot(stagingDir);
|
||||
|
||||
Log?.Invoke($"updater: writing install helper {batchPath}");
|
||||
File.WriteAllText(batchPath, BuildInstallScript(stagingRoot, baseDir));
|
||||
|
||||
var pid = System.Environment.ProcessId;
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "cmd.exe",
|
||||
Arguments = $"/c \"\"{batchPath}\" {pid}\"",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = baseDir,
|
||||
};
|
||||
Log?.Invoke($"updater: launching install helper, parent PID {pid}");
|
||||
System.Diagnostics.Process.Start(psi);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.Invoke($"updater: install failed: {ex.GetType().Name}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One-shot installer batch. Waits for the supplied PID to exit (so file locks
|
||||
/// release), robocopies the staged folder over the install folder, removes the staging
|
||||
/// area, restarts RemSound.exe, and self-deletes. Robocopy's <c>/R:5 /W:1</c> flags give
|
||||
/// the audio threads a few extra seconds to wind down if the exe is slow to release. The
|
||||
/// helper is detached from RemSound at start time, so it survives the parent's exit.</summary>
|
||||
private static string BuildInstallScript(string stagingRoot, string installDir) =>
|
||||
$"""
|
||||
@echo off
|
||||
setlocal
|
||||
rem RemSound auto-installer helper. Generated by RemSoundUpdater. Self-deleting.
|
||||
set "PID=%~1"
|
||||
:wait_loop
|
||||
tasklist /FI "PID eq %PID%" 2>nul | find "%PID%" >nul
|
||||
if not errorlevel 1 (
|
||||
timeout /t 1 /nobreak >nul
|
||||
goto wait_loop
|
||||
)
|
||||
robocopy "{stagingRoot}" "{installDir}" /E /IS /IT /NFL /NDL /NJH /NJS /R:5 /W:1 /XF _apply-update.cmd >nul
|
||||
rmdir /S /Q "{Path.Combine(installDir, "_update")}" 2>nul
|
||||
start "" "{Path.Combine(installDir, "RemSound.exe")}"
|
||||
del "%~f0"
|
||||
""";
|
||||
|
||||
/// <summary>If the zip extracted to a single subfolder (typical when GitHub zips a tag),
|
||||
/// return that subfolder so the copy works from the inner level. Otherwise return the
|
||||
/// staging dir itself.</summary>
|
||||
private static string ResolveStagingRoot(string stagingDir)
|
||||
{
|
||||
var subdirs = Directory.GetDirectories(stagingDir);
|
||||
var files = Directory.GetFiles(stagingDir);
|
||||
if (files.Length == 0 && subdirs.Length == 1) return subdirs[0];
|
||||
return stagingDir;
|
||||
}
|
||||
|
||||
/// <summary>Parses a release tag like <c>v1.2</c> or <c>1.2.3</c> into a <see cref="Version"/>.
|
||||
/// Leading "v" is stripped. Missing minor/build parts get filled with zeros so the result
|
||||
/// always compares meaningfully against <see cref="Assembly.GetName"/>.Version.</summary>
|
||||
public static Version ParseTag(string tag)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tag)) return new Version(0, 0, 0);
|
||||
var trimmed = tag.TrimStart('v', 'V').Trim();
|
||||
var parts = trimmed.Split('.', '-', '+');
|
||||
var nums = new int[3];
|
||||
for (var i = 0; i < 3 && i < parts.Length; i++)
|
||||
{
|
||||
int.TryParse(parts[i], out nums[i]);
|
||||
}
|
||||
return new Version(nums[0], nums[1], nums[2]);
|
||||
}
|
||||
|
||||
private static HttpClient CreateClient()
|
||||
{
|
||||
var c = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(20),
|
||||
};
|
||||
// GitHub rejects API requests without a User-Agent. The header doubles as a way for
|
||||
// their abuse team to contact us if our polling misbehaves at scale.
|
||||
c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("RemSound-Updater", "1.0"));
|
||||
return c;
|
||||
}
|
||||
|
||||
private static void TryDelete(string path) { try { if (File.Exists(path)) File.Delete(path); } catch { /* ignore */ } }
|
||||
private static void TryDeleteDirectory(string path) { try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } catch { /* ignore */ } }
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private sealed class GitHubRelease
|
||||
{
|
||||
[JsonPropertyName("tag_name")] public string? TagName { get; set; }
|
||||
[JsonPropertyName("body")] public string? Body { get; set; }
|
||||
[JsonPropertyName("html_url")] public string? HtmlUrl { get; set; }
|
||||
[JsonPropertyName("assets")] public List<GitHubAsset>? Assets { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GitHubAsset
|
||||
{
|
||||
[JsonPropertyName("name")] public string? Name { get; set; }
|
||||
[JsonPropertyName("browser_download_url")] public string? BrowserDownloadUrl { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What <see cref="RemSoundUpdater.CheckForUpdateAsync"/> returns when there's a
|
||||
/// newer release available. <see cref="ReleaseNotes"/> is the raw Markdown body of the
|
||||
/// release on GitHub — show it directly in a confirmation dialog if the install isn't
|
||||
/// silent.</summary>
|
||||
internal sealed record UpdateInfo(
|
||||
string Tag,
|
||||
Version Version,
|
||||
string DownloadUrl,
|
||||
string ReleaseNotes,
|
||||
string ReleaseUrl);
|
||||
@@ -0,0 +1,90 @@
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Wires "run RemSound automatically when this user logs in" via the per-user Run
|
||||
/// registry key at <c>HKCU\Software\Microsoft\Windows\CurrentVersion\Run</c>. This is the
|
||||
/// same mechanism many Windows apps use for "launch on login"; the entry shows up in Task
|
||||
/// Manager's Startup tab so the user can also toggle it off there if they ever want to.
|
||||
///
|
||||
/// Why HKCU\...\Run rather than dropping a .lnk into the Startup folder:
|
||||
/// - No COM interop / IShellLink wrangling; just RegistryKey.SetValue.
|
||||
/// - User-scoped (HKCU): no admin elevation needed and only affects this user.
|
||||
/// - Manageable via Task Manager → Startup, which is where Windows users now expect to
|
||||
/// find login-launched apps.
|
||||
///
|
||||
/// All methods catch all exceptions and return success bools — flipping the toggle in the
|
||||
/// Startup behaviour dialog should never throw, even on policy-locked machines.
|
||||
/// </summary>
|
||||
internal static class StartupAutoStart
|
||||
{
|
||||
private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
|
||||
private const string ValueName = "RemSound";
|
||||
|
||||
/// <summary>True when an entry called "RemSound" exists under the per-user Run key.
|
||||
/// Reads the registry each call (cheap; single key open + value read). Never throws.</summary>
|
||||
public static bool IsEnabled
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: false);
|
||||
if (key is null) return false;
|
||||
var value = key.GetValue(ValueName) as string;
|
||||
return !string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Add or update the Run-key entry to point at the currently-running exe.
|
||||
/// Quotes the path so spaces work. Returns true on success.</summary>
|
||||
public static bool TryEnable()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: true)
|
||||
?? Registry.CurrentUser.CreateSubKey(RunKeyPath, writable: true);
|
||||
if (key is null) return false;
|
||||
var exePath = Environment.ProcessPath;
|
||||
if (string.IsNullOrEmpty(exePath))
|
||||
{
|
||||
// Fallback: use AppContext.BaseDirectory. .NET hosting produces a
|
||||
// different process path for self-contained vs framework-dependent
|
||||
// publish, but BaseDirectory is reliable.
|
||||
exePath = System.IO.Path.Combine(AppContext.BaseDirectory, "RemSound.exe");
|
||||
}
|
||||
// Wrap in double-quotes so a path containing spaces (e.g. C:\Program Files\)
|
||||
// parses correctly when Windows launches it.
|
||||
key.SetValue(ValueName, $"\"{exePath}\"");
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Remove the Run-key entry. Returns true if the entry is gone afterwards
|
||||
/// (whether we deleted it or it never existed). Returns false only on registry
|
||||
/// access errors.</summary>
|
||||
public static bool TryDisable()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: true);
|
||||
if (key is null) return true; // No Run subkey at all → nothing to disable.
|
||||
key.DeleteValue(ValueName, throwOnMissingValue: false);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Modal dialog for the three "what should RemSound do at launch" toggles:
|
||||
/// * Start minimised — main window goes to the tray immediately after Show.
|
||||
/// * Start RemSound automatically with this user — wires HKCU\...\Run.
|
||||
/// * Start with a specific profile — skips the startup picker and loads the chosen
|
||||
/// profile directly. Companion listbox of saved profiles appears alongside.
|
||||
///
|
||||
/// The dialog persists changes through <see cref="AppConfig"/> (StartMinimised /
|
||||
/// StartWithProfileTitle) and through the Windows registry (the auto-start checkbox).
|
||||
/// Each change is committed immediately, no OK/Apply button — same per-tick-saves
|
||||
/// pattern as the rest of the Profiles and preferences tab.
|
||||
///
|
||||
/// Keyboard shape (per Ed's spec):
|
||||
/// * Tab cycles: minimised checkbox → auto-start checkbox → specific-profile
|
||||
/// checkbox → profiles list (when shown) → Close button.
|
||||
/// * Esc or the Close button closes.
|
||||
/// * Each checkbox has an Alt+letter mnemonic.
|
||||
/// </summary>
|
||||
internal sealed class StartupBehaviourDialog : Form
|
||||
{
|
||||
private readonly AccessibleCheckBox startMinimisedBox = new()
|
||||
{
|
||||
Text = "Start minimised to tray (Alt+&M)",
|
||||
AccessibleName = "Start minimised to tray",
|
||||
AutoSize = true,
|
||||
};
|
||||
private readonly AccessibleCheckBox startWithUserBox = new()
|
||||
{
|
||||
Text = "Start RemSound automatically when this user logs in (Alt+&A)",
|
||||
AccessibleName = "Start RemSound automatically when this user logs in",
|
||||
AutoSize = true,
|
||||
};
|
||||
private readonly AccessibleCheckBox startWithProfileBox = new()
|
||||
{
|
||||
Text = "Start with a specific profile (Alt+&P)",
|
||||
AccessibleName = "Start with a specific profile",
|
||||
AutoSize = true,
|
||||
};
|
||||
private readonly Label profileListLabel = new()
|
||||
{
|
||||
Text = "Profile to start with (Alt+&L):",
|
||||
AutoSize = true,
|
||||
AccessibleName = "Profile to start with",
|
||||
};
|
||||
private readonly ListBox profileList = new()
|
||||
{
|
||||
IntegralHeight = false,
|
||||
Width = 360,
|
||||
Height = 140,
|
||||
AccessibleName = "Profile to start with",
|
||||
};
|
||||
private readonly Button closeButton = new()
|
||||
{
|
||||
Text = "Close",
|
||||
AutoSize = true,
|
||||
DialogResult = DialogResult.OK,
|
||||
AccessibleName = "Close",
|
||||
};
|
||||
|
||||
public StartupBehaviourDialog(ProfileStore? profileStore)
|
||||
{
|
||||
Text = "Startup behaviour";
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MinimizeBox = false;
|
||||
MaximizeBox = false;
|
||||
ShowInTaskbar = false;
|
||||
KeyPreview = true; // form-level Esc handler
|
||||
ClientSize = new Size(540, 360);
|
||||
|
||||
// === Layout ===
|
||||
var root = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
Padding = new Padding(12),
|
||||
ColumnCount = 1,
|
||||
RowCount = 6, // 0 intro, 1 minimise, 2 auto-start, 3 specific-profile, 4 list (with label), 5 close
|
||||
};
|
||||
root.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
for (var i = 0; i < 5; i++) root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
|
||||
var intro = new Label
|
||||
{
|
||||
Text = "These options control what RemSound does when it launches. They persist across sessions and affect every launch (whether started by the user or by Windows on login).",
|
||||
AutoSize = true,
|
||||
MaximumSize = new Size(500, 0),
|
||||
Anchor = AnchorStyles.Left,
|
||||
};
|
||||
root.Controls.Add(intro, 0, 0);
|
||||
|
||||
// Each checkbox lives on its own row, plus the profile list row when relevant.
|
||||
root.Controls.Add(startMinimisedBox, 0, 1);
|
||||
root.Controls.Add(startWithUserBox, 0, 2);
|
||||
root.Controls.Add(startWithProfileBox, 0, 3);
|
||||
|
||||
// Profile list: label + list stacked, hidden until startWithProfileBox is ticked.
|
||||
// FlowLayoutPanel keeps them tidy and the whole sub-block can be toggled visible
|
||||
// as a unit.
|
||||
var listSubPanel = new FlowLayoutPanel
|
||||
{
|
||||
FlowDirection = FlowDirection.TopDown,
|
||||
AutoSize = true,
|
||||
WrapContents = false,
|
||||
Padding = new Padding(20, 0, 0, 0), // indent slightly so it visually belongs to the checkbox above
|
||||
};
|
||||
listSubPanel.Controls.Add(profileListLabel);
|
||||
listSubPanel.Controls.Add(profileList);
|
||||
root.Controls.Add(listSubPanel, 0, 4);
|
||||
|
||||
var closePanel = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
FlowDirection = FlowDirection.RightToLeft,
|
||||
AutoSize = true,
|
||||
Padding = new Padding(0, 8, 0, 0),
|
||||
};
|
||||
closePanel.Controls.Add(closeButton);
|
||||
root.Controls.Add(closePanel, 0, 5);
|
||||
|
||||
Controls.Add(root);
|
||||
|
||||
// === Tab order ===
|
||||
startMinimisedBox.TabIndex = 0;
|
||||
startWithUserBox.TabIndex = 1;
|
||||
startWithProfileBox.TabIndex = 2;
|
||||
profileList.TabIndex = 3;
|
||||
closeButton.TabIndex = 4;
|
||||
|
||||
// === Initial state ===
|
||||
var cfg = AppConfig.Load();
|
||||
startMinimisedBox.Checked = cfg.StartMinimised;
|
||||
startWithUserBox.Checked = StartupAutoStart.IsEnabled;
|
||||
var hasProfile = !string.IsNullOrWhiteSpace(cfg.StartWithProfileTitle);
|
||||
startWithProfileBox.Checked = hasProfile;
|
||||
|
||||
// Populate profile list and select the saved choice if any.
|
||||
if (profileStore is not null)
|
||||
{
|
||||
foreach (var title in profileStore.ListProfileTitles())
|
||||
{
|
||||
profileList.Items.Add(title);
|
||||
}
|
||||
}
|
||||
if (hasProfile && cfg.StartWithProfileTitle is { } savedTitle)
|
||||
{
|
||||
var idx = profileList.Items.IndexOf(savedTitle);
|
||||
if (idx >= 0) profileList.SelectedIndex = idx;
|
||||
}
|
||||
|
||||
UpdateProfileListVisibility();
|
||||
|
||||
// === Wiring ===
|
||||
startMinimisedBox.CheckedChanged += (_, _) =>
|
||||
{
|
||||
var c = AppConfig.Load();
|
||||
c.StartMinimised = startMinimisedBox.Checked;
|
||||
try { c.Save(); } catch (Exception ex) { ShowSaveWarning("Could not save Start minimised preference: " + ex.Message); }
|
||||
};
|
||||
|
||||
startWithUserBox.CheckedChanged += (_, _) =>
|
||||
{
|
||||
// Source of truth for the auto-start state is the registry — we don't keep a
|
||||
// duplicate in AppConfig. So this just flips the registry entry directly.
|
||||
var ok = startWithUserBox.Checked
|
||||
? StartupAutoStart.TryEnable()
|
||||
: StartupAutoStart.TryDisable();
|
||||
if (!ok)
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
"RemSound could not change the auto-start setting in the Windows registry. The setting did not change. (This usually means a policy or another security tool is blocking it.)",
|
||||
"Auto-start change failed",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
// Re-read truth and reflect it without re-firing this handler.
|
||||
var actual = StartupAutoStart.IsEnabled;
|
||||
if (startWithUserBox.Checked != actual)
|
||||
{
|
||||
// Temporarily detach the handler to avoid a recursive call.
|
||||
var savedChecked = actual;
|
||||
startWithUserBox.CheckedChanged -= AutoStartReentryGuard;
|
||||
startWithUserBox.Checked = savedChecked;
|
||||
startWithUserBox.CheckedChanged += AutoStartReentryGuard;
|
||||
}
|
||||
}
|
||||
};
|
||||
// Empty handler used as a target-for-removal in the re-entry-guard path above.
|
||||
// Kept so the +=/-= pair is symmetrical even though it does nothing on its own.
|
||||
void AutoStartReentryGuard(object? _, EventArgs __) { }
|
||||
|
||||
startWithProfileBox.CheckedChanged += (_, _) =>
|
||||
{
|
||||
UpdateProfileListVisibility();
|
||||
if (startWithProfileBox.Checked)
|
||||
{
|
||||
if (profileList.Items.Count == 0)
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
"You don't have any saved profiles yet. Save a profile first (Profiles and preferences tab → Save profile as), then come back here and pick it.",
|
||||
"No saved profiles",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Information);
|
||||
// Untick without re-firing.
|
||||
startWithProfileBox.Checked = false;
|
||||
return;
|
||||
}
|
||||
if (profileList.SelectedIndex < 0) profileList.SelectedIndex = 0;
|
||||
CommitProfileSelection();
|
||||
}
|
||||
else
|
||||
{
|
||||
ClearProfileSelection();
|
||||
}
|
||||
};
|
||||
|
||||
profileList.SelectedIndexChanged += (_, _) =>
|
||||
{
|
||||
if (!startWithProfileBox.Checked) return;
|
||||
if (profileList.SelectedIndex < 0) return;
|
||||
CommitProfileSelection();
|
||||
};
|
||||
profileList.DoubleClick += (_, _) =>
|
||||
{
|
||||
// Same effect as picking a row + closing — convenient for mouse users.
|
||||
if (startWithProfileBox.Checked && profileList.SelectedIndex >= 0)
|
||||
{
|
||||
CommitProfileSelection();
|
||||
}
|
||||
DialogResult = DialogResult.OK;
|
||||
Close();
|
||||
};
|
||||
|
||||
KeyDown += (_, e) =>
|
||||
{
|
||||
if (e.KeyCode == Keys.Escape)
|
||||
{
|
||||
DialogResult = DialogResult.Cancel;
|
||||
Close();
|
||||
e.SuppressKeyPress = true;
|
||||
e.Handled = true;
|
||||
}
|
||||
};
|
||||
AcceptButton = closeButton;
|
||||
CancelButton = closeButton;
|
||||
Load += (_, _) => startMinimisedBox.Focus();
|
||||
}
|
||||
|
||||
private void UpdateProfileListVisibility()
|
||||
{
|
||||
var visible = startWithProfileBox.Checked;
|
||||
profileListLabel.Visible = visible;
|
||||
profileList.Visible = visible;
|
||||
}
|
||||
|
||||
private void CommitProfileSelection()
|
||||
{
|
||||
if (profileList.SelectedItem is not string title || string.IsNullOrWhiteSpace(title)) return;
|
||||
var c = AppConfig.Load();
|
||||
c.StartWithProfileTitle = title;
|
||||
try { c.Save(); } catch (Exception ex) { ShowSaveWarning("Could not save the start-with-profile choice: " + ex.Message); }
|
||||
}
|
||||
|
||||
private void ClearProfileSelection()
|
||||
{
|
||||
var c = AppConfig.Load();
|
||||
c.StartWithProfileTitle = null;
|
||||
try { c.Save(); } catch (Exception ex) { ShowSaveWarning("Could not save the start-with-profile choice: " + ex.Message); }
|
||||
}
|
||||
|
||||
private void ShowSaveWarning(string message)
|
||||
{
|
||||
MessageBox.Show(this, message, "Startup behaviour", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using NAudio.CoreAudioApi;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Thin wrapper around NAudio's <see cref="MMDeviceEnumerator"/> + <see cref="AudioEndpointVolume"/>
|
||||
/// to drive the Windows master volume on the system's <em>default</em> render device — the same
|
||||
/// device the system-tray volume slider and the keyboard's volume keys control.
|
||||
///
|
||||
/// Why default-render-device specifically (and not e.g. the WASAPI outputs RemSound is currently
|
||||
/// playing through): in Ed's primary use case the listener machine is using ASIO for RemSound
|
||||
/// playback, but the Windows default output device is what NVDA + browsers + everything else
|
||||
/// runs through, and that's the volume Ed wants to nudge. Targeting the default device matches
|
||||
/// what the user already mentally maps "the system volume" to. ASIO devices don't expose a
|
||||
/// MasterVolumeLevelScalar via this CoreAudio surface anyway — they have hardware gain — so the
|
||||
/// "what about ASIO?" question doesn't apply here.
|
||||
///
|
||||
/// 2026-05-11 — switched to a cached enumerator/device/endpoint-volume trio (previously each call
|
||||
/// created and disposed a fresh set). Reason: the system-volume hotkeys now allow Windows-side
|
||||
/// auto-repeat on hold (see <see cref="RemSound.Core.GlobalHotkey.Register"/>), which can fire
|
||||
/// the receiver-side handler at ~30 Hz. Each fresh enumeration is multiple COM calls into the
|
||||
/// Windows audio service; doing that 30 times a second was correlated with receive-side audio
|
||||
/// glitches in testing logs. Caching collapses every steady-state call to one VolumeStepUp/Down
|
||||
/// on the cached endpoint-volume. The cache is invalidated on any COM exception so a device
|
||||
/// hot-swap or audio-service restart self-heals on the next call.
|
||||
/// </summary>
|
||||
internal static class SystemVolumeHelper
|
||||
{
|
||||
private static readonly object cacheLock = new();
|
||||
private static MMDeviceEnumerator? cachedEnumerator;
|
||||
private static MMDevice? cachedDevice;
|
||||
|
||||
/// <summary>Bumps the default render device's master volume by Windows' native step
|
||||
/// (typically ~2% — same as one keyboard-volume-up press). Returns true on success,
|
||||
/// false if the device couldn't be enumerated (catches all exceptions to keep a remote
|
||||
/// hotkey press from ever throwing).</summary>
|
||||
public static bool TryStepUp() => TryDo(v => v.VolumeStepUp());
|
||||
|
||||
/// <summary>Mirror of <see cref="TryStepUp"/> in the down direction.</summary>
|
||||
public static bool TryStepDown() => TryDo(v => v.VolumeStepDown());
|
||||
|
||||
/// <summary>Toggles the default render device's master mute. Reads the current state,
|
||||
/// flips it, writes it back. Returns true on success.</summary>
|
||||
public static bool TryToggleMute() => TryDo(v => v.Mute = !v.Mute);
|
||||
|
||||
/// <summary>Reads the current default-render-device master volume scalar (0.0..1.0) and
|
||||
/// mute state, for diagnostic logging. Returns null on any failure. Uses the same cached
|
||||
/// endpoint as the step/mute calls.</summary>
|
||||
public static (float scalar, bool mute)? TryReadState()
|
||||
{
|
||||
lock (cacheLock)
|
||||
{
|
||||
try
|
||||
{
|
||||
var device = GetOrCreateDeviceLocked();
|
||||
if (device is null) return null;
|
||||
return (device.AudioEndpointVolume.MasterVolumeLevelScalar, device.AudioEndpointVolume.Mute);
|
||||
}
|
||||
catch
|
||||
{
|
||||
InvalidateCacheLocked();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDo(Action<AudioEndpointVolume> action)
|
||||
{
|
||||
lock (cacheLock)
|
||||
{
|
||||
try
|
||||
{
|
||||
var device = GetOrCreateDeviceLocked();
|
||||
if (device is null) return false;
|
||||
// Multimedia role matches the system tray slider's idea of "default device" on a
|
||||
// typical setup. (Console role is for system sounds; the user's default playback
|
||||
// is normally configured the same for both. Multimedia is the right default for
|
||||
// "audio I'm listening to".)
|
||||
action(device.AudioEndpointVolume);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Possible failure modes: default device changed, audio service restarted,
|
||||
// device disconnected, COM marshalling glitch. Drop the cache so the next call
|
||||
// re-enumerates fresh; the user just sees a missed tick rather than a thrown
|
||||
// exception or a stuck-stale endpoint.
|
||||
InvalidateCacheLocked();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static MMDevice? GetOrCreateDeviceLocked()
|
||||
{
|
||||
if (cachedDevice is not null) return cachedDevice;
|
||||
cachedEnumerator ??= new MMDeviceEnumerator();
|
||||
cachedDevice = cachedEnumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
|
||||
return cachedDevice;
|
||||
}
|
||||
|
||||
private static void InvalidateCacheLocked()
|
||||
{
|
||||
try { cachedDevice?.Dispose(); } catch { /* ignore */ }
|
||||
cachedDevice = null;
|
||||
// Keep the enumerator across invalidations — the enumerator itself doesn't go stale
|
||||
// when the default device changes, only the device handle does. Cheaper to keep one
|
||||
// enumerator alive for the app's lifetime than to re-create it on every device hop.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="RemSound.App"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Net;
|
||||
using NAudio.CoreAudioApi;
|
||||
using RemSound.Core;
|
||||
using RemSound.Receiver;
|
||||
using RemSound.Sender;
|
||||
|
||||
if (args.Length == 0 || args[0] is "-h" or "--help" or "help")
|
||||
{
|
||||
PrintUsage();
|
||||
return 0;
|
||||
}
|
||||
|
||||
return args[0].ToLowerInvariant() switch
|
||||
{
|
||||
"send" => RunSend(args),
|
||||
"recv" or "receive" => RunReceive(args),
|
||||
"devices" => ListDevices(),
|
||||
"loopback" => RunLoopback(args),
|
||||
_ => Unknown(args[0]),
|
||||
};
|
||||
|
||||
static int Unknown(string verb)
|
||||
{
|
||||
Console.Error.WriteLine($"Unknown verb '{verb}'.");
|
||||
PrintUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void PrintUsage()
|
||||
{
|
||||
Console.WriteLine("RemSound.Harness — minimal command-line test for the new audio engine.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Usage:");
|
||||
Console.WriteLine(" RemSound.Harness devices");
|
||||
Console.WriteLine(" List Windows render devices and their IDs.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" RemSound.Harness send <ip:port> [--opus] [--device <id>]");
|
||||
Console.WriteLine(" Capture the default (or selected) render device and send to the receiver.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" RemSound.Harness recv [--port N] [--device <id>] [--max-latency N]");
|
||||
Console.WriteLine(" Listen for RemSound packets and play them through the default (or selected) device.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" RemSound.Harness loopback [--opus] [--max-latency N]");
|
||||
Console.WriteLine(" Run sender + receiver on localhost. Useful for sanity checks; will feed the");
|
||||
Console.WriteLine(" default output back into the system, so use headphones and a different render device for the receiver.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Press Ctrl+C to stop in any mode.");
|
||||
}
|
||||
|
||||
static int ListDevices()
|
||||
{
|
||||
var enumerator = new MMDeviceEnumerator();
|
||||
var defaultRender = enumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
|
||||
var devices = enumerator.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active);
|
||||
Console.WriteLine($"{"State",-8}{"Default",-9}{"Name"}");
|
||||
foreach (var device in devices)
|
||||
{
|
||||
var marker = device.ID == defaultRender.ID ? "yes" : "";
|
||||
Console.WriteLine($"{device.State,-8}{marker,-9}{device.FriendlyName}");
|
||||
Console.WriteLine($" ID: {device.ID}");
|
||||
device.Dispose();
|
||||
}
|
||||
defaultRender.Dispose();
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int RunSend(string[] args)
|
||||
{
|
||||
if (args.Length < 2)
|
||||
{
|
||||
Console.Error.WriteLine("Missing destination. Example: RemSound.Harness send 192.168.1.42:47830");
|
||||
return 1;
|
||||
}
|
||||
if (!TryParseEndpoint(args[1], out var target))
|
||||
{
|
||||
Console.Error.WriteLine($"Could not parse '{args[1]}' as ip:port.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var codec = args.Contains("--opus") ? AudioTransportCodec.Opus : AudioTransportCodec.Pcm;
|
||||
var deviceId = ParseOption(args, "--device");
|
||||
|
||||
using var sender = new AudioSender();
|
||||
if (deviceId is not null)
|
||||
{
|
||||
sender.Configure(new[] { new CaptureSourceSpec(deviceId, CaptureKind.Loopback, deviceId) });
|
||||
}
|
||||
sender.ConfigureCodec(codec);
|
||||
sender.SetReceivers(new[] { target });
|
||||
sender.Start();
|
||||
|
||||
Console.WriteLine($"Sending {codec} from \"{sender.CaptureDeviceName}\" → {target}. Press Ctrl+C to stop.");
|
||||
using var quit = new ManualResetEventSlim(false);
|
||||
Console.CancelKeyPress += (_, e) => { e.Cancel = true; quit.Set(); };
|
||||
var lastPackets = 0L;
|
||||
while (!quit.Wait(1000))
|
||||
{
|
||||
var p = sender.PacketsSent;
|
||||
var rate = p - lastPackets;
|
||||
lastPackets = p;
|
||||
Console.WriteLine($"[send] packets={p} /sec={rate} bytes={sender.BytesSent} uptime={sender.Uptime:hh\\:mm\\:ss}");
|
||||
}
|
||||
sender.Stop();
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int RunReceive(string[] args)
|
||||
{
|
||||
var port = int.TryParse(ParseOption(args, "--port"), out var p) ? p : RemPacket.DefaultPort;
|
||||
var deviceId = ParseOption(args, "--device");
|
||||
var maxLatency = int.TryParse(ParseOption(args, "--max-latency"), out var ml) ? ml : 80;
|
||||
|
||||
using var receiver = new AudioReceiver();
|
||||
if (deviceId is not null) receiver.SetOutputDevices(new[] { deviceId });
|
||||
receiver.MaxLatencyMs = maxLatency;
|
||||
receiver.Start(port);
|
||||
|
||||
Console.WriteLine($"Listening on UDP :{port}, output \"{receiver.OutputDeviceName}\", max latency {maxLatency} ms. Press Ctrl+C to stop.");
|
||||
using var quit = new ManualResetEventSlim(false);
|
||||
Console.CancelKeyPress += (_, e) => { e.Cancel = true; quit.Set(); };
|
||||
var lastPackets = 0L;
|
||||
while (!quit.Wait(1000))
|
||||
{
|
||||
var pk = receiver.PacketsReceived;
|
||||
var rate = pk - lastPackets;
|
||||
lastPackets = pk;
|
||||
Console.WriteLine($"[recv] packets={pk} /sec={rate} buffer={receiver.CurrentBufferMs}ms underruns={receiver.Underruns} drops={receiver.Drops}");
|
||||
}
|
||||
receiver.Stop();
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int RunLoopback(string[] args)
|
||||
{
|
||||
var codec = args.Contains("--opus") ? AudioTransportCodec.Opus : AudioTransportCodec.Pcm;
|
||||
var maxLatency = int.TryParse(ParseOption(args, "--max-latency"), out var ml) ? ml : 80;
|
||||
|
||||
using var receiver = new AudioReceiver();
|
||||
receiver.MaxLatencyMs = maxLatency;
|
||||
receiver.Start();
|
||||
|
||||
using var sender = new AudioSender();
|
||||
sender.ConfigureCodec(codec);
|
||||
sender.SetReceivers(new[] { new IPEndPoint(IPAddress.Loopback, RemPacket.DefaultPort) });
|
||||
sender.Start();
|
||||
|
||||
Console.WriteLine($"Loopback running, codec={codec}, max latency={maxLatency} ms. Ctrl+C to stop.");
|
||||
using var quit = new ManualResetEventSlim(false);
|
||||
Console.CancelKeyPress += (_, e) => { e.Cancel = true; quit.Set(); };
|
||||
while (!quit.Wait(1000))
|
||||
{
|
||||
Console.WriteLine($"send pkt={sender.PacketsSent} recv pkt={receiver.PacketsReceived} buf={receiver.CurrentBufferMs}ms under={receiver.Underruns} drop={receiver.Drops}");
|
||||
}
|
||||
sender.Stop();
|
||||
receiver.Stop();
|
||||
return 0;
|
||||
}
|
||||
|
||||
static bool TryParseEndpoint(string text, out IPEndPoint endpoint)
|
||||
{
|
||||
endpoint = new IPEndPoint(IPAddress.Loopback, 0);
|
||||
var split = text.Split(':');
|
||||
if (split.Length != 2) return false;
|
||||
if (!IPAddress.TryParse(split[0], out var ip)) return false;
|
||||
if (!int.TryParse(split[1], out var port) || port is <= 0 or > 65535) return false;
|
||||
endpoint = new IPEndPoint(ip, port);
|
||||
return true;
|
||||
}
|
||||
|
||||
static string? ParseOption(string[] args, string optionName)
|
||||
{
|
||||
for (var i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
if (string.Equals(args[i], optionName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return args[i + 1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<RootNamespace>RemSound.Harness</RootNamespace>
|
||||
<AssemblyName>RemSound.Harness</AssemblyName>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\RemSound.Core\RemSound.Core.csproj" />
|
||||
<ProjectReference Include="..\RemSound.Sender\RemSound.Sender.csproj" />
|
||||
<ProjectReference Include="..\RemSound.Receiver\RemSound.Receiver.csproj" />
|
||||
<PackageReference Include="NAudio" Version="2.3.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,244 @@
|
||||
using NAudio.Wave;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// ASIO render backend. Drives a single <see cref="AsioOut"/> for the chosen ASIO driver,
|
||||
/// pulling the receiver's mixed stereo audio from <see cref="PlayoutEngine"/> and broadcasting
|
||||
/// it across one or more output channel pairs of the driver. Same shape as
|
||||
/// <see cref="MultiOutputPlayout"/>: <see cref="AudioReceiver"/> doesn't care which is active.
|
||||
///
|
||||
/// Spec identity: each output ID is a synthetic <c>"asio:<channel-pair-index>"</c>. Pair 0
|
||||
/// = ASIO output channels 0+1, pair 1 = 2+3, etc. The driver itself is locked at construction.
|
||||
///
|
||||
/// Same simplifications as <see cref="AsioCaptureBackend"/>: 48 kHz fixed; driver is single
|
||||
/// per session. Always opens the AsioOut with the driver's full output channel count so that
|
||||
/// adding/removing channel pairs never requires reopening the driver — important when the
|
||||
/// sender and receiver are both holding the same single-client driver (Komplete Audio etc.):
|
||||
/// reopening one while the other is alive caused 15-second freezes.
|
||||
/// </summary>
|
||||
internal sealed class AsioRenderBackend : IRenderBackend
|
||||
{
|
||||
private const int MixSampleRate = 48000;
|
||||
private const int MixChannels = 2;
|
||||
|
||||
// Same reasoning as MultiOutputPlayout — source typed as IWaveProvider so the composite
|
||||
// backend can hand us a tee'd buffer.
|
||||
private readonly IWaveProvider source;
|
||||
private readonly Action<string>? onDiagnostic;
|
||||
private readonly string driverName;
|
||||
private readonly object gate = new();
|
||||
|
||||
private AsioOut? asio;
|
||||
private List<int> activeChannelPairs = [];
|
||||
private BroadcastProvider? broadcaster;
|
||||
|
||||
public AsioRenderBackend(string driverName, IWaveProvider source, Action<string>? onDiagnostic = null)
|
||||
{
|
||||
this.driverName = driverName;
|
||||
this.source = source;
|
||||
this.onDiagnostic = onDiagnostic;
|
||||
}
|
||||
|
||||
public bool IsRunning => asio is not null;
|
||||
|
||||
public string ActiveDeviceSummary
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (activeChannelPairs.Count == 0) return "(none)";
|
||||
var names = activeChannelPairs.Select(p => $"{driverName} ASIO {p * 2 + 1}/{p * 2 + 2}").ToList();
|
||||
if (names.Count <= 3) return string.Join(", ", names);
|
||||
return $"({names.Count} ASIO outputs)";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> ActiveDeviceIds
|
||||
{
|
||||
get { lock (gate) return activeChannelPairs.Select(AsioDeviceId.Format).ToList(); }
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
// ASIO render starts lazily when SetOutputDevices is given a non-empty list. There's no
|
||||
// useful "open driver but render to nothing" state — that just locks the device with no
|
||||
// benefit. The MixingEngine equivalent (producer loop) for WASAPI runs continuously
|
||||
// even with zero outputs to keep state alive; ASIO doesn't need that since the AsioOut
|
||||
// *is* the output and there's nothing to keep alive when no channels are wanted.
|
||||
// Caller is expected to call SetOutputDevices first; this method is a no-op when empty.
|
||||
lock (gate)
|
||||
{
|
||||
if (IsRunning) return;
|
||||
if (activeChannelPairs.Count == 0) return;
|
||||
OpenAsioLocked();
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
lock (gate) StopInternal();
|
||||
}
|
||||
|
||||
public void SetOutputDevices(IReadOnlyList<string> deviceIds)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var newPairs = ParsePairs(deviceIds);
|
||||
if (newPairs.Count == 0)
|
||||
{
|
||||
if (IsRunning) StopInternal();
|
||||
activeChannelPairs = newPairs;
|
||||
return;
|
||||
}
|
||||
|
||||
activeChannelPairs = newPairs;
|
||||
|
||||
// First time we have any pairs → open the driver. Otherwise we never reopen on a
|
||||
// pair-set change, because we already opened with the driver's full channel count
|
||||
// at Start time. Just update the broadcaster's pair list and we're done.
|
||||
if (asio is null)
|
||||
{
|
||||
OpenAsioLocked();
|
||||
return;
|
||||
}
|
||||
broadcaster?.SetActivePairs(activeChannelPairs);
|
||||
onDiagnostic?.Invoke($"asio render: pairs updated to {string.Join(",", activeChannelPairs)} (no driver restart)");
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenAsioLocked()
|
||||
{
|
||||
try
|
||||
{
|
||||
asio = new AsioOut(driverName);
|
||||
// Always open with the driver's full output channel count. Channels we don't
|
||||
// immediately broadcast to are zero-filled by BroadcastProvider, which is
|
||||
// essentially free. Trades a tiny bit of buffer memory for a big stability win:
|
||||
// adding or removing an output pair never reopens the driver — see the type
|
||||
// doc-comment for why this matters with single-client drivers.
|
||||
var outputChannelCount = asio.DriverOutputChannelCount;
|
||||
if (outputChannelCount <= 0)
|
||||
{
|
||||
onDiagnostic?.Invoke($"asio render: driver \"{driverName}\" reports zero output channels");
|
||||
StopInternal();
|
||||
return;
|
||||
}
|
||||
// Sanity-check requested pairs are in range; warn if not but continue (out-of-range
|
||||
// pairs simply get no audio).
|
||||
var maxPair = activeChannelPairs.Max();
|
||||
var highestNeededChannel = (maxPair + 1) * 2;
|
||||
if (highestNeededChannel > outputChannelCount)
|
||||
{
|
||||
onDiagnostic?.Invoke($"asio render: driver \"{driverName}\" only has {outputChannelCount} output channels, but spec requests pair {maxPair} (channels {maxPair * 2 + 1}/{maxPair * 2 + 2})");
|
||||
}
|
||||
broadcaster = new BroadcastProvider(source, outputChannelCount, activeChannelPairs);
|
||||
asio.ChannelOffset = 0;
|
||||
asio.Init(broadcaster);
|
||||
asio.Play();
|
||||
onDiagnostic?.Invoke($"asio render started \"{driverName}\" {MixSampleRate} Hz, {outputChannelCount} output channel(s); pairs={string.Join(",", activeChannelPairs)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
onDiagnostic?.Invoke($"asio render start failed: {ex.GetType().Name}: {ex.Message}");
|
||||
StopInternal();
|
||||
}
|
||||
}
|
||||
|
||||
private void StopInternal()
|
||||
{
|
||||
if (asio is not null)
|
||||
{
|
||||
try { asio.Stop(); } catch { /* ignore */ }
|
||||
try { asio.Dispose(); } catch { /* ignore */ }
|
||||
asio = null;
|
||||
}
|
||||
broadcaster = null;
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
private static List<int> ParsePairs(IReadOnlyList<string> deviceIds)
|
||||
{
|
||||
var result = new List<int>();
|
||||
foreach (var id in deviceIds)
|
||||
{
|
||||
if (AsioDeviceId.TryParse(id, out var pair) && pair >= 0)
|
||||
{
|
||||
result.Add(pair);
|
||||
}
|
||||
}
|
||||
result.Sort();
|
||||
return result.Distinct().ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wave provider that pulls stereo audio from <see cref="PlayoutEngine"/> and writes it to
|
||||
/// a multi-channel ASIO buffer at the requested channel pair positions, zero-filling the
|
||||
/// channels that aren't selected. Output is interleaved 32-bit float at 48 kHz, exactly
|
||||
/// what NAudio's AsioOut wants.
|
||||
/// </summary>
|
||||
private sealed class BroadcastProvider : IWaveProvider
|
||||
{
|
||||
private readonly IWaveProvider source;
|
||||
private readonly int outputChannelCount;
|
||||
private byte[] sourceScratchBytes = new byte[16384];
|
||||
private List<int> activePairs;
|
||||
|
||||
public WaveFormat WaveFormat { get; }
|
||||
|
||||
public BroadcastProvider(IWaveProvider source, int outputChannelCount, List<int> activePairs)
|
||||
{
|
||||
this.source = source;
|
||||
this.outputChannelCount = outputChannelCount;
|
||||
this.activePairs = new List<int>(activePairs);
|
||||
WaveFormat = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, outputChannelCount);
|
||||
}
|
||||
|
||||
public void SetActivePairs(IEnumerable<int> pairs)
|
||||
{
|
||||
// Atomic swap. Read side reads activePairs once per Read so a partial swap is
|
||||
// tolerable — at worst we get one tick of stale routing.
|
||||
activePairs = pairs.ToList();
|
||||
}
|
||||
|
||||
public int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
// Frame size in BYTES on the output side.
|
||||
var bytesPerOutputFrame = outputChannelCount * sizeof(float);
|
||||
var frames = count / bytesPerOutputFrame;
|
||||
if (frames <= 0) return 0;
|
||||
|
||||
// Pull stereo from PlayoutEngine — its WaveFormat is 48k stereo float, so 8 bytes
|
||||
// per frame.
|
||||
var sourceBytes = frames * MixChannels * sizeof(float);
|
||||
if (sourceScratchBytes.Length < sourceBytes) sourceScratchBytes = new byte[sourceBytes];
|
||||
source.Read(sourceScratchBytes, 0, sourceBytes);
|
||||
|
||||
// Interpret source bytes as float array, output bytes as float array, broadcast.
|
||||
var srcFloats = System.Runtime.InteropServices.MemoryMarshal.Cast<byte, float>(sourceScratchBytes.AsSpan(0, sourceBytes));
|
||||
var dstFloats = System.Runtime.InteropServices.MemoryMarshal.Cast<byte, float>(buffer.AsSpan(offset, count));
|
||||
dstFloats.Clear();
|
||||
|
||||
var pairs = activePairs;
|
||||
for (var f = 0; f < frames; f++)
|
||||
{
|
||||
var l = srcFloats[f * MixChannels];
|
||||
var r = srcFloats[f * MixChannels + 1];
|
||||
var dstFrameStart = f * outputChannelCount;
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
var lCh = pair * 2;
|
||||
var rCh = pair * 2 + 1;
|
||||
if (lCh < outputChannelCount) dstFloats[dstFrameStart + lCh] = l;
|
||||
if (rCh < outputChannelCount) dstFloats[dstFrameStart + rCh] = r;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// Public façade for the receiver pipeline. Routes raw packets from <see cref="NetworkListener"/>
|
||||
/// to one <see cref="StreamSession"/> per remote sender, all of which write to their own
|
||||
/// <see cref="SessionPlayout"/>; the <see cref="PlayoutEngine"/> then mixes those at render time.
|
||||
///
|
||||
/// Multi-source rationale: the previous design held a single <c>activeSession</c> and reset the
|
||||
/// playout buffer whenever a Format packet arrived from a different endpoint. With two senders
|
||||
/// transmitting to the same receiver simultaneously (peer-to-peer plus a localhost-monitor, or
|
||||
/// a future conferencing setup), Format packets alternated and the buffer flushed several times
|
||||
/// per second — the crackle the WAN test surfaced. Now each endpoint gets its own session and
|
||||
/// playout state, all summed at the render output.
|
||||
///
|
||||
/// Idle sessions are pruned: any session that hasn't received audio data in
|
||||
/// <see cref="SessionIdleTimeout"/> is removed by <see cref="PruneIdleSessions"/>, called by the
|
||||
/// App's snapshot tick.
|
||||
///
|
||||
/// Responsibilities deliberately scoped:
|
||||
/// * Lifecycle (Start / Stop / Dispose).
|
||||
/// * Public configuration (max latency, volume, mute, output device).
|
||||
/// * Routing packets to the right session, creating sessions for new endpoints.
|
||||
/// </summary>
|
||||
public sealed class AudioReceiver : IDisposable
|
||||
{
|
||||
public const int MixSampleRate = 48000;
|
||||
public const int MixChannels = 2;
|
||||
private const int MixBytesPerSecond = MixSampleRate * MixChannels * sizeof(float);
|
||||
|
||||
/// <summary>How big each session's AudioRingBuffer is sized — enough to absorb burst arrival
|
||||
/// over the maximum supported latency without dropping. Values much above the user-set max
|
||||
/// latency just waste memory; below it can drop on a deep WAN burst.</summary>
|
||||
private const int CapacityHeadroomMultiplier = 8;
|
||||
private const int MaxLatencyForSizingMs = 500;
|
||||
|
||||
/// <summary>Sessions that have received nothing for this long are pruned. Long enough that a
|
||||
/// brief silent gap (mute / no input) doesn't kill the session, short enough that a peer that
|
||||
/// truly stops sending doesn't keep occupying state forever (and inflating the underrun
|
||||
/// counter — every render read of an empty-but-armed session bumps the underrun count even
|
||||
/// though the mix output is unaffected).</summary>
|
||||
public static readonly TimeSpan SessionIdleTimeout = TimeSpan.FromSeconds(4);
|
||||
|
||||
private readonly Stopwatch uptime = new();
|
||||
private readonly ReceiverDiagnostics diagnostics = new();
|
||||
private readonly PlayoutEngine playoutEngine;
|
||||
private IRenderBackend multiOutput;
|
||||
private readonly NetworkListener listener;
|
||||
private Action<string>? diagnosticSink;
|
||||
|
||||
private readonly object sessionsLock = new();
|
||||
// Sessions are keyed by (Endpoint, StreamId) — 2026-05-11. A peer can produce
|
||||
// multiple simultaneous streams (e.g. WASAPI lane + ASIO lane in the native-
|
||||
// independent audio mode). For single-lane modes the sender emits one streamId so
|
||||
// the dict still has one entry per peer, identical to the pre-refactor behaviour.
|
||||
private readonly Dictionary<(IPEndPoint Endpoint, ushort StreamId), StreamSession> sessions = new();
|
||||
|
||||
/// <summary>When false (the default), a Format packet arriving with a NEW streamId from
|
||||
/// a peer that already has a session under a DIFFERENT streamId triggers immediate
|
||||
/// disposal of the old session — preserves the pre-refactor "one peer = one active
|
||||
/// session" behaviour. The sender legitimately rotates streamId on codec changes /
|
||||
/// engine restarts; without this, the old SessionPlayout sits empty for 4 seconds
|
||||
/// until <see cref="PruneIdleSessions"/> fires, racking up phantom underrun counts
|
||||
/// from the render thread polling its empty buffer (~100 per second).
|
||||
///
|
||||
/// Set true in the native-independent audio mode (Stage 4) where two streamIds from
|
||||
/// the same peer are expected to coexist (WASAPI lane + ASIO lane). In that mode the
|
||||
/// auto-dispose-old-on-new-streamId is wrong — both lanes are continuously active.</summary>
|
||||
public bool AllowMultipleStreamsPerPeer { get; set; }
|
||||
|
||||
// True when audio playback is enabled — i.e. multiOutput is started and Format/Audio
|
||||
// packets should be processed into sessions. False means the listener stays bound
|
||||
// (so the single-port heartbeat path keeps working) but audio packets are discarded
|
||||
// before any decode/buffer work, and no SessionPlayout is created. Volatile because
|
||||
// packet handlers run on the network thread and may observe a SetPlaybackEnabled
|
||||
// toggle at any moment. See the single-port unification (2026-05-06): the listener
|
||||
// is bound for the duration of a connection so heartbeat packets always reach
|
||||
// OnHeartbeatReceived, regardless of the user's "Receive audio" tick state.
|
||||
private volatile bool playbackEnabled;
|
||||
|
||||
// Allowed-senders gate. The App ticks peer checkboxes; only those endpoints' audio reaches
|
||||
// the playout. A null set means "no filter" (legacy behaviour). An empty set means "block
|
||||
// everyone". Stored as IP addresses (not full IPEndPoint) because incoming packets carry
|
||||
// the sender's *outbound* (ephemeral) source port, not the port we'd see in their
|
||||
// announcement — comparing port-included would always fail. The peer is identified by
|
||||
// machine IP; we accept audio from any source port on that IP. Read on the network thread,
|
||||
// updated from the UI thread via SetAllowedSenders.
|
||||
private volatile HashSet<IPAddress>? allowedSenders;
|
||||
|
||||
private long packetsReceived;
|
||||
private long bytesReceived;
|
||||
private long packetsDropped;
|
||||
private long packetsRejectedNotAllowed;
|
||||
|
||||
public AudioReceiver()
|
||||
{
|
||||
playoutEngine = new PlayoutEngine(diagnostics);
|
||||
multiOutput = new CompositeRenderBackend(AudioMode.WasapiOnly, null, playoutEngine, msg => diagnosticSink?.Invoke($"output: {msg}"));
|
||||
listener = new NetworkListener(HandleRawPacket, msg => diagnosticSink?.Invoke($"network: {msg}"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the audio backend mode (and ASIO driver, when ASIO is involved) for the render side.
|
||||
/// Mirrors AudioSender.SetAudioMode. The App should re-issue SetOutputDevices afterwards with
|
||||
/// the current device-id selection.
|
||||
/// </summary>
|
||||
public void SetAudioMode(AudioMode mode, string? asioDriverName)
|
||||
{
|
||||
var wasRunning = multiOutput.IsRunning;
|
||||
try { multiOutput.Stop(); } catch { /* ignore */ }
|
||||
try { multiOutput.Dispose(); } catch { /* ignore */ }
|
||||
multiOutput = new CompositeRenderBackend(mode, asioDriverName, playoutEngine, msg => diagnosticSink?.Invoke($"output: {msg}"));
|
||||
if (wasRunning) multiOutput.Start();
|
||||
}
|
||||
|
||||
public bool IsAsioBackend => multiOutput is CompositeRenderBackend;
|
||||
|
||||
/// <summary>Sets the Buffer-smoothness knob (1 = aggressive — clicks the buffer back
|
||||
/// to target on any drift, holds the user's latency tightly; 10 = smooth — no clicks
|
||||
/// but the queue can creep up under jitter or sustained clock drift). Knob drives a
|
||||
/// click-based DropOldest trim in <see cref="SessionPlayout.ReadFloats"/>. As of the
|
||||
/// 2026-05-06 cleanup (Phase 3) this is mostly a safety knob — the Phase-2 drift
|
||||
/// corrector keeps the buffer near target so the trim should rarely fire regardless of
|
||||
/// this value.</summary>
|
||||
public void SetSmoothness(int value) => playoutEngine.SetSmoothness(value);
|
||||
|
||||
/// <summary>Sets the concealment artifact used when the playout buffer comes up empty
|
||||
/// on a render-side read. Pure receiver-side cosmetic — sender doesn't see this.
|
||||
/// Live: takes effect on the next underrun, no need to restart playback.</summary>
|
||||
public void SetConcealmentArtifact(ConcealmentArtifact artifact) =>
|
||||
playoutEngine.SetConcealmentArtifact(artifact);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the allow-list of sender endpoints whose audio will be rendered. Pass an empty set
|
||||
/// to block all (the user has selected no peers); pass null to disable filtering and accept
|
||||
/// everyone (test/diagnostic only — production UI always passes a real set).
|
||||
///
|
||||
/// Why this exists: without an allow-list, anyone who can reach our UDP port (e.g. a peer
|
||||
/// who has us in *their* selected list, or a stale broadcast announcement that another
|
||||
/// instance starts honouring) gets their audio rendered to our speakers automatically. The
|
||||
/// user expects audio to play only after they explicitly tick a peer's checkbox; this gate
|
||||
/// implements that contract.
|
||||
///
|
||||
/// The filter is applied at packet receipt — Format and Audio packets from non-allowed
|
||||
/// endpoints are counted but discarded, no SessionPlayout is created, no playout buffer
|
||||
/// fills. Discovery and heartbeat (separate UDP ports) are unaffected, so non-allowed
|
||||
/// peers still appear as "discovered" in the UI ready to be ticked.
|
||||
/// </summary>
|
||||
public void SetAllowedSenders(IEnumerable<IPEndPoint>? allowed)
|
||||
{
|
||||
// Reduce IPEndPoint inputs to bare IPAddress for the gate; see field-comment for why.
|
||||
var snapshot = allowed is null ? null : new HashSet<IPAddress>(allowed.Select(ep => ep.Address));
|
||||
allowedSenders = snapshot;
|
||||
// Tear down sessions for endpoints that just got removed from the allow-list — without
|
||||
// this, audio would keep playing from a session that was opened before the user
|
||||
// unticked its checkbox. Match by IP since that's how the gate works.
|
||||
if (snapshot is not null)
|
||||
{
|
||||
List<StreamSession> toClose = [];
|
||||
lock (sessionsLock)
|
||||
{
|
||||
foreach (var (key, session) in sessions)
|
||||
{
|
||||
if (!snapshot.Contains(key.Endpoint.Address))
|
||||
{
|
||||
toClose.Add(session);
|
||||
}
|
||||
}
|
||||
foreach (var session in toClose)
|
||||
{
|
||||
sessions.Remove((session.Endpoint, session.StreamId));
|
||||
}
|
||||
}
|
||||
foreach (var session in toClose)
|
||||
{
|
||||
playoutEngine.RemoveSession(session.Endpoint, session.StreamId);
|
||||
session.Dispose();
|
||||
diagnosticSink?.Invoke($"stream session closed (sender no longer in selected peers): {session.Endpoint} stream={session.StreamId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cumulative count of audio/format packets dropped because the sender wasn't in
|
||||
/// the allow-list. Surfaced via diagnostics so we can confirm the filter is working.</summary>
|
||||
public long PacketsRejectedNotAllowed => Interlocked.Read(ref packetsRejectedNotAllowed);
|
||||
|
||||
private bool IsSenderAllowed(IPEndPoint remote)
|
||||
{
|
||||
var snapshot = allowedSenders;
|
||||
if (snapshot is null) return true; // null = no filter
|
||||
return snapshot.Contains(remote.Address);
|
||||
}
|
||||
|
||||
/// <summary>Optional diagnostic sink (App writes to log file).</summary>
|
||||
public Action<string>? Diagnostic { get => diagnosticSink; set => diagnosticSink = value; }
|
||||
|
||||
/// <summary>True when audio playback is active — i.e. <see cref="SetPlaybackEnabled"/>
|
||||
/// has been called with <c>true</c> and the underlying render backend is running. This
|
||||
/// matches the previous semantic of "the user has Receive audio on and we're rendering".
|
||||
/// The UDP listener socket is NOT covered by this flag — see <see cref="IsListenerRunning"/>.
|
||||
/// In single-port mode (post-2026-05-06) the listener stays bound for the whole connection
|
||||
/// so heartbeat packets always reach us; this flag tracks only the playback half.</summary>
|
||||
public bool IsRunning => multiOutput.IsRunning;
|
||||
/// <summary>True when the UDP listener socket is bound. Independent of playback state.
|
||||
/// Surfaced for diagnostic/symmetry only — most callers want <see cref="IsRunning"/>.</summary>
|
||||
public bool IsListenerRunning => listener.IsRunning;
|
||||
/// <summary>Max time-in-user-handler (the work between Socket.ReceiveFrom returning and
|
||||
/// onPacket finishing) observed since the last call. The SNAP loop reads this each
|
||||
/// second to split observed inter-packet jitter into network vs receiver-processing
|
||||
/// contributions. Resets on read.</summary>
|
||||
public int TakeMaxOnPacketMs() => listener.TakeMaxOnPacketMs();
|
||||
|
||||
/// <summary>Worst FanOutSource cache-occupancy seen since the last call, expressed in
|
||||
/// milliseconds at the mix rate (48 kHz stereo float). With one active render lane the
|
||||
/// FanOut should drain to ~0 after every consumer Read; sustained non-zero means a
|
||||
/// render lane is holding samples (slow consumer holding back compaction, or the fast
|
||||
/// consumer not draining quickly enough). Zero in WasapiOnly mode (no FanOut). Resets
|
||||
/// on read. Added 2026-05-11 to verify the BothIndependent FanOut path isn't quietly
|
||||
/// inflating latency on either lane.</summary>
|
||||
public int TakeMaxFanOutCacheMs()
|
||||
{
|
||||
// 48000 Hz × 2 ch × 4 bytes/sample = 384,000 bytes/sec.
|
||||
const int MixBytesPerSecond = 48000 * 2 * 4;
|
||||
var bytes = (multiOutput as CompositeRenderBackend)?.TakeMaxFanOutCacheBytes() ?? 0;
|
||||
return bytes * 1000 / MixBytesPerSecond;
|
||||
}
|
||||
public string OutputDeviceName => multiOutput.ActiveDeviceSummary;
|
||||
public int CurrentBufferMs => playoutEngine.CurrentBufferMs;
|
||||
public int TargetLatencyMs => playoutEngine.TargetLatencyMs;
|
||||
|
||||
/// <summary>
|
||||
/// Frame duration of the most-recently-active stream (10 ms PCM, 20 ms Opus). null when no
|
||||
/// stream is active. With multiple senders this picks the largest frame duration as the
|
||||
/// codec floor — most conservative for the auto-tune.
|
||||
/// </summary>
|
||||
public int? ActiveStreamFrameMs
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (sessionsLock)
|
||||
{
|
||||
if (sessions.Count == 0) return null;
|
||||
var maxFrame = 0;
|
||||
foreach (var s in sessions.Values)
|
||||
{
|
||||
if (s.Format.FrameDurationMilliseconds > maxFrame) maxFrame = s.Format.FrameDurationMilliseconds;
|
||||
}
|
||||
return maxFrame;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Aggregate count across all active PCM sessions of frames the assembler rejected.
|
||||
/// Resets per-session when a session ends; the receiver-level number is the live sum.</summary>
|
||||
public long PcmFrameRejections
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (sessionsLock)
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var s in sessions.Values) total += s.PcmFrameRejections;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public long PcmFrameDiscardedPartials
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (sessionsLock)
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var s in sessions.Values) total += s.PcmFrameDiscardedPartials;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int MaxLatencyMs
|
||||
{
|
||||
get => playoutEngine.MaxLatencyMs;
|
||||
set => playoutEngine.SetMaxLatencyMs(value);
|
||||
}
|
||||
|
||||
/// <summary>Soft variant: same as setting MaxLatencyMs, but on a LOWER does not drain
|
||||
/// the buffer / disarm the session. The drift corrector's adaptive gain shrinks the
|
||||
/// buffer gradually over a few seconds instead. Used by auto-tune so its slider
|
||||
/// adjustments are inaudible — the user didn't ask for an immediate change and shouldn't
|
||||
/// hear one. On a RAISE behaves identically to the regular setter (no drain ever fires
|
||||
/// on raise).</summary>
|
||||
public void SetMaxLatencyMsSoft(int value) =>
|
||||
playoutEngine.SetMaxLatencyMs(value, drainOnLower: false);
|
||||
|
||||
/// <summary>Per-route latency accessors — used in BothIndependent mode where the WASAPI
|
||||
/// lane and the ASIO lane each have their own slider. In classic modes only the Mixed
|
||||
/// route has sessions, so the route-specific values are configured but never observed.</summary>
|
||||
public int MaxLatencyMsFor(RenderRoute route) => playoutEngine.MaxLatencyMsFor(route);
|
||||
public int TargetLatencyMsFor(RenderRoute route) => playoutEngine.TargetLatencyMsFor(route);
|
||||
public void SetMaxLatencyMsFor(RenderRoute route, int value) =>
|
||||
playoutEngine.SetMaxLatencyMs(route, value);
|
||||
public void SetMaxLatencyMsSoftFor(RenderRoute route, int value) =>
|
||||
playoutEngine.SetMaxLatencyMs(route, value, drainOnLower: false);
|
||||
/// <summary>Per-route underrun count for the auto-tune skip-while-underrunning gate. In
|
||||
/// BothIndependent the WASAPI lane's underruns should not make the ASIO auto-tune defer
|
||||
/// (and vice versa); reading per-route fixes that.</summary>
|
||||
public long UnderrunsFor(RenderRoute route) => playoutEngine.AggregateUnderrunsFor(route);
|
||||
/// <summary>True when at least one stream session is currently tagged for this route —
|
||||
/// used by MainForm's continuous auto-tune to skip routes with no audio in flight, so a
|
||||
/// lane's auto-tune can't pre-inflate its target by reacting to shared network-gap data
|
||||
/// from a different lane's packets.</summary>
|
||||
public bool HasSessionsForRoute(RenderRoute route) => playoutEngine.HasSessionsForRoute(route);
|
||||
|
||||
public long Underruns => playoutEngine.AggregateUnderruns;
|
||||
public long Drops => playoutEngine.AggregateDrops + Interlocked.Read(ref packetsDropped);
|
||||
|
||||
/// <summary>Per-cause split of the legacy `Drops` rollup. Useful in the diag log to tell
|
||||
/// "we deliberately trimmed the buffer to track the latency target" (TrimDropBytes) from
|
||||
/// "we got malformed packets" (PacketsRejectedMalformed) from "ringbuffer overflowed and
|
||||
/// the producer dropped oldest" (RingbufferOverflowDropBytes). Without this split a single
|
||||
/// "Drops" value couldn't tell us which mechanism was firing.</summary>
|
||||
public long TrimDropBytes => playoutEngine.AggregateTrimDropBytes;
|
||||
public long DrainDropBytes => playoutEngine.AggregateDrainDropBytes;
|
||||
public long TrimFireCount => playoutEngine.AggregateTrimFireCount;
|
||||
/// <summary>Phase-2 drift correction counters: how many single stereo frames have been
|
||||
/// dropped (sender clock faster) or repeated (sender clock slower) to keep the playout
|
||||
/// buffer aligned with target. Each event = 21 µs of audio at 48 kHz, sub-audible.</summary>
|
||||
public long DriftDropFrames => playoutEngine.AggregateDriftDropFrames;
|
||||
public long DriftRepeatFrames => playoutEngine.AggregateDriftRepeatFrames;
|
||||
/// <summary>RingbufferOverflowDropBytes = AggregateDrops minus the deliberate trim+drain
|
||||
/// causes. Whatever's left was the producer-side overflow (Write into a full buffer) or
|
||||
/// the catastrophic-cap trim from NoteFramesQueued. Both indicate "we genuinely couldn't
|
||||
/// keep up", as opposed to "we deliberately reshaped the buffer".</summary>
|
||||
public long RingbufferOverflowDropBytes
|
||||
=> Math.Max(0, playoutEngine.AggregateDrops - TrimDropBytes - DrainDropBytes);
|
||||
public long PacketsRejectedMalformed => Interlocked.Read(ref packetsDropped);
|
||||
|
||||
public long PacketsReceived => Interlocked.Read(ref packetsReceived);
|
||||
public long BytesReceived => Interlocked.Read(ref bytesReceived);
|
||||
public TimeSpan Uptime => uptime.Elapsed;
|
||||
|
||||
/// <summary>Total times we used Opus inband FEC to recover a single-packet gap, across all active sessions.</summary>
|
||||
public long OpusFecRecoveries
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
lock (sessionsLock)
|
||||
{
|
||||
foreach (var s in sessions.Values) total += s.OpusFecRecoveries;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Total times we saw a multi-packet gap that FEC could not fill, across all active sessions.</summary>
|
||||
public long OpusUnrecoveredGaps
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
lock (sessionsLock)
|
||||
{
|
||||
foreach (var s in sessions.Values) total += s.OpusUnrecoveredGaps;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
public float Volume { get => playoutEngine.Volume; set => playoutEngine.Volume = value; }
|
||||
public bool IsMuted { get => playoutEngine.IsMuted; set => playoutEngine.IsMuted = value; }
|
||||
|
||||
/// <summary>
|
||||
/// Sets the list of output devices to render received audio to. The receiver mixes once and
|
||||
/// fans out to every device in this list — pass an empty list to mute all output without
|
||||
/// stopping the receive path. Per session policy, the App does NOT persist this selection;
|
||||
/// every session starts with no outputs ticked.
|
||||
/// </summary>
|
||||
public void SetOutputDevices(IReadOnlyList<string> deviceIds) => multiOutput.SetOutputDevices(deviceIds);
|
||||
|
||||
/// <summary>Take a snapshot of the rolling diagnostic counters. Caller drives at 1 Hz.</summary>
|
||||
public ReceiverDiagnostics.DiagSnapshot TakeDiagnosticsSnapshot() => diagnostics.Take(MixBytesPerSecond);
|
||||
|
||||
/// <summary>
|
||||
/// Bind the UDP listener socket on <paramref name="udpPort"/>. Does NOT start audio
|
||||
/// playback — call <see cref="SetPlaybackEnabled"/>(true) for that. Splitting these
|
||||
/// lets the single-port heartbeat path keep working while the user has "Receive audio"
|
||||
/// off: the socket stays bound so heartbeat packets reach <see cref="OnHeartbeatReceived"/>,
|
||||
/// but Format/Audio packets are discarded at receipt (no decode, no buffer growth).
|
||||
/// </summary>
|
||||
public void Start(int udpPort = RemPacket.DefaultPort)
|
||||
{
|
||||
if (listener.IsRunning) return;
|
||||
|
||||
Interlocked.Exchange(ref packetsReceived, 0);
|
||||
Interlocked.Exchange(ref bytesReceived, 0);
|
||||
Interlocked.Exchange(ref packetsDropped, 0);
|
||||
|
||||
// Tear down any sessions left over from a previous Start (in case Stop wasn't called).
|
||||
DisposeAllSessionsLocked();
|
||||
playoutEngine.ResetAll();
|
||||
|
||||
listener.Start(udpPort);
|
||||
uptime.Restart();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggles audio playback on or off. When <paramref name="enabled"/> goes false, the
|
||||
/// render backend is stopped and any open sessions are disposed (so a re-enable doesn't
|
||||
/// drain stale audio). Heartbeat packet routing is unaffected — the listener stays
|
||||
/// bound either way as long as <see cref="Start"/> has been called. Idempotent.
|
||||
/// </summary>
|
||||
public void SetPlaybackEnabled(bool enabled)
|
||||
{
|
||||
if (enabled == multiOutput.IsRunning)
|
||||
{
|
||||
playbackEnabled = enabled;
|
||||
return;
|
||||
}
|
||||
if (enabled)
|
||||
{
|
||||
// Reset packet handlers' gate before starting the backend, so packets that arrive
|
||||
// between multiOutput.Start and the next handler invocation aren't misrouted.
|
||||
playbackEnabled = true;
|
||||
multiOutput.Start();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Flip the gate first so HandleFormat/HandleAudio stop opening new sessions, then
|
||||
// tear down the backend and any in-flight sessions. Order matters — if we stopped
|
||||
// the backend first, in-flight packets could open a fresh session that nothing
|
||||
// would ever drain.
|
||||
playbackEnabled = false;
|
||||
multiOutput.Stop();
|
||||
lock (sessionsLock)
|
||||
{
|
||||
DisposeAllSessionsLocked();
|
||||
}
|
||||
playoutEngine.ResetAll();
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
listener.Stop();
|
||||
playbackEnabled = false;
|
||||
multiOutput.Stop();
|
||||
uptime.Stop();
|
||||
lock (sessionsLock)
|
||||
{
|
||||
DisposeAllSessionsLocked();
|
||||
}
|
||||
playoutEngine.ResetAll();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
listener.Dispose();
|
||||
multiOutput.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop sessions that haven't received audio data in <see cref="SessionIdleTimeout"/>. Caller
|
||||
/// (the App's snapshot tick) drives this so it stays serialised with the network thread on
|
||||
/// the same lock the packet handlers use.
|
||||
/// </summary>
|
||||
public void PruneIdleSessions()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
List<(IPEndPoint Endpoint, ushort StreamId)>? toRemove = null;
|
||||
lock (sessionsLock)
|
||||
{
|
||||
foreach (var (key, session) in sessions)
|
||||
{
|
||||
// Match SessionPlayout by full key so two streams from the same peer don't
|
||||
// share a single SessionPlayout entry. ActiveSessions iteration is small
|
||||
// (one per active stream).
|
||||
var sp = playoutEngine.ActiveSessions.FirstOrDefault(x =>
|
||||
x.Endpoint.Equals(key.Endpoint) && x.StreamId == key.StreamId);
|
||||
if (sp is null) continue;
|
||||
if (now - sp.LastWriteUtc <= SessionIdleTimeout) continue;
|
||||
toRemove ??= [];
|
||||
toRemove.Add(key);
|
||||
}
|
||||
if (toRemove is not null)
|
||||
{
|
||||
foreach (var key in toRemove)
|
||||
{
|
||||
if (sessions.Remove(key, out var session)) session.Dispose();
|
||||
playoutEngine.RemoveSession(key.Endpoint, key.StreamId);
|
||||
diagnosticSink?.Invoke($"stream session pruned (idle): {key.Endpoint} stream={key.StreamId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DisposeAllSessionsLocked()
|
||||
{
|
||||
foreach (var s in sessions.Values) s.Dispose();
|
||||
sessions.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether we have a recent audio stream session from the given peer IP. "Recent" matches the
|
||||
/// playout-engine's idle-prune timeout — i.e. a session whose last write is within
|
||||
/// <see cref="SessionIdleTimeout"/>. Compares on IP only, not port (incoming packets carry the
|
||||
/// sender's outbound source port, which won't equal their announced audio port). Lockless and
|
||||
/// safe to call from any thread.
|
||||
/// </summary>
|
||||
public bool IsReceivingFromAddress(IPAddress address)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var sp in playoutEngine.ActiveSessions)
|
||||
{
|
||||
if (!sp.Endpoint.Address.Equals(address)) continue;
|
||||
if (now - sp.LastWriteUtc <= SessionIdleTimeout) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The codec format being received from the given peer IP, or null if no recent session.
|
||||
/// Useful for surfacing "we're receiving Opus 10ms from this peer" in the UI.
|
||||
/// </summary>
|
||||
public AudioFormatInfo? ActiveFormatFromAddress(IPAddress address)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
SessionPlayout? freshest = null;
|
||||
foreach (var sp in playoutEngine.ActiveSessions)
|
||||
{
|
||||
if (!sp.Endpoint.Address.Equals(address)) continue;
|
||||
if (now - sp.LastWriteUtc > SessionIdleTimeout) continue;
|
||||
if (freshest is null || sp.LastWriteUtc > freshest.LastWriteUtc) freshest = sp;
|
||||
}
|
||||
if (freshest is null) return null;
|
||||
lock (sessionsLock)
|
||||
{
|
||||
if (sessions.TryGetValue((freshest.Endpoint, freshest.StreamId), out var session))
|
||||
{
|
||||
return session.Format;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// === Packet routing (called on network thread) ===
|
||||
|
||||
/// <summary>Hook for Heartbeat packets that arrive on the audio receiver's socket. The
|
||||
/// App wires this to <see cref="HeartbeatService.HandleInjectedPacket"/>. Set this *before*
|
||||
/// starting the receiver, otherwise heartbeats arriving on this socket will be silently
|
||||
/// dropped as unknown packet type. In single-port mode (the only mode since 2026-05-06)
|
||||
/// every heartbeat reaches us via this hook — the audio sender writes to the peer's
|
||||
/// audio port, which is this receiver's bound socket; there is no separate heartbeat
|
||||
/// socket on either end any more.</summary>
|
||||
public Action<byte[], int, IPEndPoint>? OnHeartbeatReceived { get; set; }
|
||||
|
||||
/// <summary>Hook for Control packets that arrive on the audio receiver's socket. The
|
||||
/// App wires this to a handler that validates the source against the allow-list (the
|
||||
/// peer must be in the user's selected-peers set), checks the user's "accept remote
|
||||
/// volume commands" preference, and applies the requested change to the local volume
|
||||
/// slider. Set this BEFORE starting the receiver; null = packet is silently dropped.
|
||||
/// Travels on the same UDP socket as audio + heartbeat (single-port model 2026-05-07).</summary>
|
||||
public Action<RemoteControlKind, sbyte, IPEndPoint>? OnRemoteControlReceived { get; set; }
|
||||
|
||||
private void HandleRawPacket(byte[] packet, int length, IPEndPoint remote)
|
||||
{
|
||||
Interlocked.Increment(ref packetsReceived);
|
||||
Interlocked.Add(ref bytesReceived, length);
|
||||
|
||||
var packetSpan = packet.AsSpan(0, length);
|
||||
if (!RemPacket.TryReadHeader(packetSpan, out var type, out var streamId, out var sequence))
|
||||
{
|
||||
Interlocked.Increment(ref packetsDropped);
|
||||
return;
|
||||
}
|
||||
|
||||
var payload = packetSpan[RemPacket.HeaderSize..];
|
||||
switch (type)
|
||||
{
|
||||
case RemPacketType.Format:
|
||||
HandleFormat(remote, streamId, payload);
|
||||
break;
|
||||
case RemPacketType.Audio:
|
||||
HandleAudio(remote, streamId, sequence, payload);
|
||||
break;
|
||||
case RemPacketType.KeepAlive:
|
||||
// Informational only at this layer.
|
||||
break;
|
||||
case RemPacketType.Heartbeat:
|
||||
// Route to the heartbeat service via the App-supplied delegate. In single-port
|
||||
// mode this is the primary inbound path for heartbeats (the heartbeat service
|
||||
// no longer binds its own socket). The hook MUST be wired before Start();
|
||||
// otherwise heartbeats are dropped and peer health stays "unreachable".
|
||||
OnHeartbeatReceived?.Invoke(packet, length, remote);
|
||||
break;
|
||||
case RemPacketType.Control:
|
||||
// Remote-control message (volume up/down, mute toggle). Parse the payload
|
||||
// here so the handler doesn't need to know about RemPacket layout. Caller
|
||||
// is expected to gate on allow-list AND the user's opt-in preference.
|
||||
if (RemPacket.TryReadControl(payload, out var ctrlKind, out var ctrlDelta))
|
||||
{
|
||||
OnRemoteControlReceived?.Invoke(ctrlKind, ctrlDelta, remote);
|
||||
}
|
||||
else
|
||||
{
|
||||
Interlocked.Increment(ref packetsDropped);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
Interlocked.Increment(ref packetsDropped);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inject a packet that arrived on a non-listener socket (e.g. the AudioSender's socket
|
||||
/// in relay mode). Runs the same dispatch logic as the listener thread. Caller is
|
||||
/// responsible for filtering out packet types it has handled itself (typically Heartbeat,
|
||||
/// which goes to <see cref="HeartbeatService"/>) — passing a Heartbeat packet here is
|
||||
/// safe (it'll be counted and dropped) but wasteful.
|
||||
/// </summary>
|
||||
public void InjectExternalPacket(byte[] packet, int length, IPEndPoint remote)
|
||||
{
|
||||
HandleRawPacket(packet, length, remote);
|
||||
}
|
||||
|
||||
private void HandleFormat(IPEndPoint remote, ushort streamId, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
// Single-port mode: the listener stays bound when playback is off (so heartbeats
|
||||
// keep flowing on the same socket), but Format/Audio are dropped without opening a
|
||||
// session. Doing this BEFORE the format-parse keeps the malformed-packet counter
|
||||
// honest — disabled-playback drops aren't a malformedness signal.
|
||||
if (!playbackEnabled) return;
|
||||
|
||||
if (!RemPacket.TryReadFormat(payload, out var format))
|
||||
{
|
||||
Interlocked.Increment(ref packetsDropped);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsSenderAllowed(remote))
|
||||
{
|
||||
// Sender isn't in the user's selected-peers set. Don't open a session, don't play
|
||||
// their audio. They'll appear in discovery / heartbeat as a peer the user can tick
|
||||
// if they want; until then, silence on our side. Counted separately so it shows in
|
||||
// diagnostics without inflating the generic "drops" stat.
|
||||
Interlocked.Increment(ref packetsRejectedNotAllowed);
|
||||
return;
|
||||
}
|
||||
|
||||
SessionPlayout sp;
|
||||
StreamSession? newSession = null;
|
||||
bool isNewSession = false;
|
||||
bool isFormatChange = false;
|
||||
// Older sessions from the same peer that are being replaced because we're in
|
||||
// single-stream mode (AllowMultipleStreamsPerPeer=false) and the sender rotated
|
||||
// its streamId (codec change / engine restart). Disposed AFTER releasing the
|
||||
// sessionsLock so their tear-down doesn't extend the critical section.
|
||||
List<StreamSession>? supersededByStreamIdChange = null;
|
||||
|
||||
var key = (remote, streamId);
|
||||
lock (sessionsLock)
|
||||
{
|
||||
sessions.TryGetValue(key, out var existing);
|
||||
if (existing is not null && existing.MatchesFormat(remote, streamId, format))
|
||||
{
|
||||
return; // same session; nothing to do
|
||||
}
|
||||
|
||||
sp = playoutEngine.GetOrCreateSession(remote, streamId, MaxBufferCapacityBytes(MaxLatencyForSizingMs));
|
||||
// Tag the session with the wire-announced render route. For classic-mode senders
|
||||
// (or pre-2026-05-11 builds) this is always Mixed and PlayoutEngine treats the
|
||||
// session exactly as it always did. BothIndependent senders will tag their two
|
||||
// lanes with WasapiLane / AsioLane so the per-route surfaces direct each lane to
|
||||
// the matching render backend without mixing. Updated unconditionally so an
|
||||
// in-place format change can re-route a session (e.g. a sender that mistakenly
|
||||
// started in classic mode and re-announces with the right lane mid-stream).
|
||||
sp.Route = format.Lane;
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
isNewSession = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Same (endpoint, streamId), different format (codec change within the same lane).
|
||||
// Replace the StreamSession but keep its SessionPlayout — buffered audio drains
|
||||
// naturally and avoids a gap. Matches the behaviour the single-source code
|
||||
// preserved for codec switches.
|
||||
existing.Dispose();
|
||||
isFormatChange = true;
|
||||
}
|
||||
|
||||
newSession = new StreamSession(remote, streamId, format, sp, diagnostics, _ => sp.NoteFramesQueued(playoutEngine.TargetLatencyMs));
|
||||
sessions[key] = newSession;
|
||||
|
||||
// Same-lane streamId rotation: drop other sessions from this peer that share the
|
||||
// SAME render route as the new format. The sender rotates streamId on codec
|
||||
// changes and engine restarts; the old session sits empty otherwise, racking up
|
||||
// phantom underruns from render-thread polling. The lane-match qualifier is
|
||||
// critical for BothIndependent mode (added 2026-05-11) where the same peer
|
||||
// legitimately produces TWO concurrent streamIds — one per lane — and each lane's
|
||||
// Format-resend packets must NOT supersede the other lane's session. Without the
|
||||
// lane match, the two lanes' 250 ms format announces took turns killing each
|
||||
// other 8× per second, neither lane could stay alive long enough to arm, and
|
||||
// BothIndependent appeared to "produce no audio" on the receiver. AllowMultiple-
|
||||
// StreamsPerPeer is preserved as an override knob (default false) for unusual
|
||||
// setups; even with it true, lane-mismatched sessions would still coexist, so the
|
||||
// flag now only governs same-lane-different-streamId behaviour.
|
||||
if (!AllowMultipleStreamsPerPeer)
|
||||
{
|
||||
foreach (var (otherKey, otherSession) in sessions)
|
||||
{
|
||||
if (otherKey.Endpoint.Equals(remote)
|
||||
&& otherKey.StreamId != streamId
|
||||
&& otherSession.Format.Lane == format.Lane)
|
||||
{
|
||||
supersededByStreamIdChange ??= [];
|
||||
supersededByStreamIdChange.Add(otherSession);
|
||||
}
|
||||
}
|
||||
if (supersededByStreamIdChange is not null)
|
||||
{
|
||||
foreach (var s in supersededByStreamIdChange)
|
||||
{
|
||||
sessions.Remove((s.Endpoint, s.StreamId));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (supersededByStreamIdChange is not null)
|
||||
{
|
||||
foreach (var s in supersededByStreamIdChange)
|
||||
{
|
||||
playoutEngine.RemoveSession(s.Endpoint, s.StreamId);
|
||||
s.Dispose();
|
||||
diagnosticSink?.Invoke($"stream session superseded (sender rotated streamId): {s.Endpoint} oldStream={s.StreamId} newStream={streamId}");
|
||||
}
|
||||
}
|
||||
|
||||
if (isNewSession)
|
||||
{
|
||||
// Reset the global inter-packet / inter-render-callback gap timers. If we don't,
|
||||
// the first audio packet of this new session records a gap measured from the LAST
|
||||
// packet of the previous session — which on a mode switch or codec change can be
|
||||
// tens of seconds of user-idle time. That bogus gap then feeds the auto-tune's
|
||||
// recent-gap window and makes it recommend an absurd latency target (e.g. 27 s
|
||||
// observed → recommendation clamped to 200 ms hard cap → fresh session never
|
||||
// arms because its buffer can't reach 200 ms before underrun). 2026-05-11 fix.
|
||||
diagnostics.ResetGapMeasurements();
|
||||
Interlocked.Increment(ref sessionsOpenedCount);
|
||||
diagnosticSink?.Invoke($"stream session opened: {remote} stream={streamId} {format}");
|
||||
}
|
||||
else if (isFormatChange)
|
||||
{
|
||||
diagnosticSink?.Invoke($"stream format changed: {remote} stream={streamId} {format}");
|
||||
}
|
||||
}
|
||||
|
||||
private long sessionsOpenedCount;
|
||||
/// <summary>
|
||||
/// Monotonic count of new <c>StreamSession</c> instances opened since this receiver
|
||||
/// started. Exposed so the App can detect a fresh session and reset its rolling
|
||||
/// observation windows (recentMaxGaps etc.) — see the matching reset in MainForm's
|
||||
/// SNAP loop. Increments only on truly-new sessions, not on format-change-keep-buffer.
|
||||
/// </summary>
|
||||
public long SessionsOpenedCount => Interlocked.Read(ref sessionsOpenedCount);
|
||||
|
||||
private void HandleAudio(IPEndPoint remote, ushort streamId, uint sequence, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
// See HandleFormat — same single-port gate. We drop Audio packets silently when
|
||||
// playback is off; the underlying NAT pinhole / heartbeat path isn't affected since
|
||||
// Heartbeat packets are dispatched in HandleRawPacket before reaching here.
|
||||
if (!playbackEnabled) return;
|
||||
if (!IsSenderAllowed(remote))
|
||||
{
|
||||
Interlocked.Increment(ref packetsRejectedNotAllowed);
|
||||
return;
|
||||
}
|
||||
StreamSession? session;
|
||||
lock (sessionsLock)
|
||||
{
|
||||
sessions.TryGetValue((remote, streamId), out session);
|
||||
}
|
||||
// Key lookup guarantees streamId match — kept the defensive check anyway in case of
|
||||
// future restructuring (cheap and clarifies intent).
|
||||
if (session is null) return;
|
||||
if (session.StreamId != streamId) return;
|
||||
if (!session.HandleAudioPayload(sequence, payload))
|
||||
{
|
||||
Interlocked.Increment(ref packetsDropped);
|
||||
}
|
||||
}
|
||||
|
||||
private static int MaxBufferCapacityBytes(int maxLatencyMs) =>
|
||||
Math.Max(maxLatencyMs * CapacityHeadroomMultiplier * MixBytesPerSecond / 1000, 64 * 1024);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// Render backend that runs a WASAPI <see cref="MultiOutputPlayout"/> and an
|
||||
/// <see cref="AsioRenderBackend"/> in parallel. Two pipeline shapes are reachable today:
|
||||
/// <list type="bullet">
|
||||
/// <item>WasapiOnly: WASAPI child reads <see cref="PlayoutEngine"/> directly; no ASIO in
|
||||
/// the path. Used when no ASIO driver is selected.</item>
|
||||
/// <item>BothIndependent: WASAPI and ASIO children each get their own consumer view from a
|
||||
/// shared <see cref="FanOutSource"/>. The FanOut pulls from PlayoutEngine on demand
|
||||
/// and caches so both views see the same samples without one consumer slowing the
|
||||
/// other. Neither backend pays the classic-Both master-producer tee's ~5–10 ms
|
||||
/// buffer headroom — each lane runs at its native callback rate.</item>
|
||||
/// </list>
|
||||
/// The legacy <c>AudioMode.Both</c> tee mode and <c>AudioMode.AsioOnly</c> values are no
|
||||
/// longer reachable from the UI and are not produced here.
|
||||
/// </summary>
|
||||
internal sealed class CompositeRenderBackend : IRenderBackend
|
||||
{
|
||||
private readonly PlayoutEngine source;
|
||||
private readonly Action<string>? onDiagnostic;
|
||||
private readonly object gate = new();
|
||||
|
||||
// BothIndependent no longer uses a shared FanOut between the two render backends — each
|
||||
// backend reads directly from its own lane-filtered source (PlayoutEngine.WasapiLaneOutput /
|
||||
// AsioLaneOutput). Those surfaces filter PlayoutEngine's session snapshot by RenderRoute,
|
||||
// so the WASAPI consumer's Read only advances WasapiLane sessions and the ASIO consumer's
|
||||
// Read only advances AsioLane sessions. The two lanes are fully independent — no shared
|
||||
// cache, no cross-lane interference, neither lane pays a cache-age penalty when the other
|
||||
// is also playing.
|
||||
private readonly MultiOutputPlayout? wasapi;
|
||||
private readonly AsioRenderBackend? asio;
|
||||
private readonly string? asioDriverName;
|
||||
private readonly RemSound.Core.AudioMode mode;
|
||||
|
||||
private bool started;
|
||||
|
||||
public CompositeRenderBackend(RemSound.Core.AudioMode mode, string? asioDriverName, PlayoutEngine source, Action<string>? onDiagnostic = null)
|
||||
{
|
||||
this.source = source;
|
||||
this.onDiagnostic = onDiagnostic;
|
||||
this.asioDriverName = asioDriverName;
|
||||
this.mode = mode;
|
||||
|
||||
// Coerce legacy enum values (AsioOnly, Both) into a reachable mode. Anything non-
|
||||
// WASAPI without a driver demotes to WasapiOnly; anything non-WASAPI with a driver
|
||||
// is treated as BothIndependent (the only ASIO-using render mode now).
|
||||
if (mode != RemSound.Core.AudioMode.WasapiOnly)
|
||||
{
|
||||
if (string.IsNullOrEmpty(asioDriverName))
|
||||
{
|
||||
this.mode = mode = RemSound.Core.AudioMode.WasapiOnly;
|
||||
}
|
||||
else if (mode != RemSound.Core.AudioMode.BothIndependent)
|
||||
{
|
||||
this.mode = mode = RemSound.Core.AudioMode.BothIndependent;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode == RemSound.Core.AudioMode.WasapiOnly)
|
||||
{
|
||||
// MultiOutputPlayout reads PlayoutEngine directly — no master producer, no tee.
|
||||
// Sessions in WasapiOnly mode are all on RenderRoute.Mixed (the legacy single-knob
|
||||
// world), and the all-sessions Read does the right thing.
|
||||
wasapi = new MultiOutputPlayout(source, msg => onDiagnostic?.Invoke($"wasapi out: {msg}"));
|
||||
}
|
||||
else
|
||||
{
|
||||
// BothIndependent. Each backend reads its OWN lane-filtered source from
|
||||
// PlayoutEngine — no FanOut, no shared cache, no inter-lane interference. The
|
||||
// WasapiLaneOutput surface filters PlayoutEngine's session snapshot down to
|
||||
// route=WasapiLane sessions; AsioLaneOutput does the same for route=AsioLane.
|
||||
// Each consumer's Read only advances its own lane's sessions, so the two
|
||||
// consumers can run on independent threads at independent rates without one
|
||||
// starving the other. Crucially: neither lane pays a cache-age overhead. ASIO
|
||||
// reads its own audio at its native callback latency, exactly as it would in a
|
||||
// hypothetical AsioOnly setup — even when WASAPI is also actively playing.
|
||||
// The previous implementation wrapped a single FanOut around the whole engine,
|
||||
// which (a) made both lanes play the combined mix instead of per-lane audio and
|
||||
// (b) added up to one WASAPI tick (~10 ms) of cache-age latency to whichever
|
||||
// consumer was the slower of the two.
|
||||
wasapi = new MultiOutputPlayout(source.WasapiLaneOutput, msg => onDiagnostic?.Invoke($"wasapi out: {msg}"));
|
||||
asio = new AsioRenderBackend(asioDriverName!, source.AsioLaneOutput, msg => onDiagnostic?.Invoke($"asio out: {msg}"));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsRunning => started;
|
||||
|
||||
/// <summary>Legacy probe from the FanOut era — always 0 now that BothIndependent reads
|
||||
/// per-lane sources directly with no intermediate cache. Kept on the surface so the
|
||||
/// receiver-side diag plumbing (fanCacheMs= column) keeps emitting a sentinel zero
|
||||
/// rather than disappearing. Can be removed once we're confident the per-lane wiring
|
||||
/// is the right shape long-term.</summary>
|
||||
public int TakeMaxFanOutCacheBytes() => 0;
|
||||
|
||||
public string ActiveDeviceSummary
|
||||
{
|
||||
get
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (wasapi is not null)
|
||||
{
|
||||
var wSummary = wasapi.ActiveDeviceSummary;
|
||||
if (wSummary != "(none)") parts.Add(wSummary);
|
||||
}
|
||||
if (asio is not null)
|
||||
{
|
||||
var aSummary = asio.ActiveDeviceSummary;
|
||||
if (aSummary != "(none)") parts.Add(aSummary);
|
||||
}
|
||||
return parts.Count == 0 ? "(none)" : string.Join(" + ", parts);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> ActiveDeviceIds
|
||||
{
|
||||
get
|
||||
{
|
||||
var combined = new List<string>();
|
||||
if (wasapi is not null) combined.AddRange(wasapi.ActiveDeviceIds);
|
||||
if (asio is not null) combined.AddRange(asio.ActiveDeviceIds);
|
||||
return combined;
|
||||
}
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (started) return;
|
||||
wasapi?.Start();
|
||||
asio?.Start();
|
||||
started = true;
|
||||
onDiagnostic?.Invoke($"composite render started (mode={ModeLabel()})");
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (!started) return;
|
||||
try { wasapi?.Stop(); } catch { /* ignore */ }
|
||||
try { asio?.Stop(); } catch { /* ignore */ }
|
||||
started = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetOutputDevices(IReadOnlyList<string> deviceIds)
|
||||
{
|
||||
// Split by id format: ASIO ids start with "asio:". WASAPI ids are MMDevice strings.
|
||||
var wasapiIds = new List<string>();
|
||||
var asioIds = new List<string>();
|
||||
foreach (var id in deviceIds)
|
||||
{
|
||||
if (RemSound.Core.AsioDeviceId.TryParse(id, out _))
|
||||
{
|
||||
asioIds.Add(id);
|
||||
}
|
||||
else
|
||||
{
|
||||
wasapiIds.Add(id);
|
||||
}
|
||||
}
|
||||
if (wasapi is not null) wasapi.SetOutputDevices(wasapiIds);
|
||||
if (asio is not null) asio.SetOutputDevices(asioIds);
|
||||
// No FanOut bookkeeping any more — each lane's source is independent, so consumer
|
||||
// activity / inactivity doesn't affect the other lane's read path. The "skip the
|
||||
// pull when no outputs are ticked" behaviour now lives inside MultiOutputPlayout's
|
||||
// producer loop, which short-circuits source.Read when outputs.Count == 0.
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
try { wasapi?.Dispose(); } catch { /* ignore */ }
|
||||
try { asio?.Dispose(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
private string ModeLabel() => mode switch
|
||||
{
|
||||
RemSound.Core.AudioMode.WasapiOnly => "fast (WASAPI direct)",
|
||||
RemSound.Core.AudioMode.BothIndependent => "independent lanes (WASAPI + ASIO, no mix)",
|
||||
_ => mode.ToString(),
|
||||
};
|
||||
|
||||
// FanOutSource and SwitchableSource have been removed (2026-05-13). The BothIndependent
|
||||
// rewiring put each lane on its own filtered PlayoutEngine.{Wasapi,Asio}LaneOutput
|
||||
// surface, so there is no shared source for two consumers to fight over and no cache
|
||||
// to manage. Either class can be reintroduced if a future routing shape needs them.
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the render-side audio backend so <see cref="AudioReceiver"/> can be wired
|
||||
/// to either a WASAPI implementation (today's <see cref="MultiOutputPlayout"/>) or an ASIO
|
||||
/// implementation (<see cref="AsioRenderBackend"/>) without caring which is in use.
|
||||
///
|
||||
/// Both backends pull mixed audio from <see cref="PlayoutEngine"/>'s <see cref="IWaveProvider"/>
|
||||
/// surface and route it to one or more output destinations. WASAPI destinations are MMDevice
|
||||
/// IDs; ASIO destinations are synthetic IDs of the form
|
||||
/// <c>"asio:<driver-name>|<channel-pair-index>"</c>.
|
||||
/// </summary>
|
||||
internal interface IRenderBackend : IDisposable
|
||||
{
|
||||
bool IsRunning { get; }
|
||||
|
||||
/// <summary>Friendly summary for the snapshot log column. "(none)" when nothing is
|
||||
/// configured, comma-joined names for ≤3 outputs, "(N outputs)" otherwise.</summary>
|
||||
string ActiveDeviceSummary { get; }
|
||||
|
||||
IReadOnlyList<string> ActiveDeviceIds { get; }
|
||||
|
||||
void Start();
|
||||
|
||||
void Stop();
|
||||
|
||||
/// <summary>Live-update of the output set. Devices already present stay live; removed ones
|
||||
/// are torn down; new ones are opened. Empty list = render to nothing without stopping the
|
||||
/// mixer (so receive-side state stays alive).</summary>
|
||||
void SetOutputDevices(IReadOnlyList<string> deviceIds);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using System.Diagnostics;
|
||||
using NAudio.CoreAudioApi;
|
||||
using NAudio.Wave;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// Drives N WASAPI output devices from a single shared <see cref="PlayoutEngine"/>. A master
|
||||
/// producer task running on a Stopwatch-based 10 ms tick reads mixed audio from the engine and
|
||||
/// fans it out to each device's <see cref="BufferedWaveProvider"/>; each <see cref="WasapiOut"/>
|
||||
/// consumes from its own buffer at its own device clock.
|
||||
///
|
||||
/// Why a master producer loop instead of letting one WasapiOut drive PlayoutEngine.Read directly:
|
||||
/// - With multiple WasapiOuts, each render thread would call Read independently and only one
|
||||
/// output would get each frame; the others would starve.
|
||||
/// - The producer loop runs at the canonical 48 kHz / 10 ms cadence, decoupled from any one
|
||||
/// device's clock. Per-device drift is absorbed by the BufferedWaveProvider's headroom.
|
||||
///
|
||||
/// Output-device set is diffed on <see cref="SetOutputDevices"/>: existing devices stay live,
|
||||
/// removed ones are stopped, new ones are opened. No audio interruption to the unchanged ones.
|
||||
/// </summary>
|
||||
internal sealed class MultiOutputPlayout : IRenderBackend
|
||||
{
|
||||
private const int MixSampleRate = 48000;
|
||||
private const int MixChannels = 2;
|
||||
private const int MixBytesPerFrame = MixChannels * sizeof(float);
|
||||
private const int FrameMs = 10;
|
||||
private const int FrameBytes = MixSampleRate * MixBytesPerFrame * FrameMs / 1000; // 3840 bytes
|
||||
private const int OutputBufferMs = 100; // per-device BufferedWaveProvider capacity
|
||||
|
||||
// Source typed as IWaveProvider (rather than concrete PlayoutEngine) so the composite
|
||||
// backend can hand us a tee'd buffer instead of the engine directly. Single-backend usage
|
||||
// still passes the engine in unchanged.
|
||||
private readonly IWaveProvider source;
|
||||
private readonly Action<string>? onDiagnostic;
|
||||
private readonly object gate = new();
|
||||
private readonly Dictionary<string, OutputEntry> outputs = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly byte[] frameScratch = new byte[FrameBytes];
|
||||
private readonly WaveFormat sharedFormat = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, MixChannels);
|
||||
|
||||
private CancellationTokenSource? cts;
|
||||
private Task? produceTask;
|
||||
|
||||
public MultiOutputPlayout(IWaveProvider source, Action<string>? onDiagnostic = null)
|
||||
{
|
||||
this.source = source;
|
||||
this.onDiagnostic = onDiagnostic;
|
||||
}
|
||||
|
||||
public bool IsRunning => produceTask is { IsCompleted: false };
|
||||
|
||||
/// <summary>
|
||||
/// Friendly names of currently-active output devices, comma-joined. "(none)" when no
|
||||
/// device is enabled. Used by the snapshot log column.
|
||||
/// </summary>
|
||||
public string ActiveDeviceSummary
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (outputs.Count == 0) return "(none)";
|
||||
if (outputs.Count <= 3) return string.Join(", ", outputs.Values.Select(o => o.Name));
|
||||
return $"({outputs.Count} outputs)";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> ActiveDeviceIds
|
||||
{
|
||||
get { lock (gate) return outputs.Keys.ToList(); }
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (IsRunning) return;
|
||||
cts = new CancellationTokenSource();
|
||||
produceTask = Task.Run(() => ProduceLoop(cts.Token));
|
||||
onDiagnostic?.Invoke("multi-output producer started");
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
try { cts?.Cancel(); } catch { /* ignore */ }
|
||||
try { produceTask?.Wait(TimeSpan.FromMilliseconds(500)); } catch { /* ignore */ }
|
||||
cts?.Dispose();
|
||||
cts = null;
|
||||
produceTask = null;
|
||||
|
||||
foreach (var o in outputs.Values) DisposeOutput(o);
|
||||
outputs.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
/// <summary>
|
||||
/// Live-update of the output device set. Devices already present stay live (no audio
|
||||
/// interruption); removed devices are stopped + disposed; new devices are opened. Caller
|
||||
/// supplies device IDs (from MMDeviceEnumerator). An empty set means "render to nothing"
|
||||
/// — the producer loop keeps running so receive-side mixing/auto-tune state stays alive.
|
||||
/// </summary>
|
||||
public void SetOutputDevices(IReadOnlyList<string> deviceIds)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var desired = new HashSet<string>(deviceIds, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Remove outputs no longer wanted.
|
||||
foreach (var id in outputs.Keys.Where(k => !desired.Contains(k)).ToList())
|
||||
{
|
||||
if (outputs.Remove(id, out var o))
|
||||
{
|
||||
onDiagnostic?.Invoke($"output removed: \"{o.Name}\"");
|
||||
DisposeOutput(o);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new outputs.
|
||||
using var enumerator = new MMDeviceEnumerator();
|
||||
foreach (var id in deviceIds)
|
||||
{
|
||||
if (outputs.ContainsKey(id)) continue;
|
||||
MMDevice? device = null;
|
||||
WasapiOut? wasapi = null;
|
||||
try
|
||||
{
|
||||
device = enumerator.GetDevice(id);
|
||||
var name = device.FriendlyName;
|
||||
var buffer = new BufferedWaveProvider(sharedFormat)
|
||||
{
|
||||
ReadFully = true,
|
||||
DiscardOnBufferOverflow = true,
|
||||
BufferDuration = TimeSpan.FromMilliseconds(OutputBufferMs),
|
||||
};
|
||||
wasapi = new WasapiOut(device, AudioClientShareMode.Shared, useEventSync: true, latency: 15);
|
||||
wasapi.Init(buffer);
|
||||
wasapi.Play();
|
||||
outputs[id] = new OutputEntry { Device = device, Output = wasapi, Buffer = buffer, Name = name };
|
||||
onDiagnostic?.Invoke($"output added: \"{name}\"");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
onDiagnostic?.Invoke($"failed to open output \"{id}\": {ex.GetType().Name}: {ex.Message}");
|
||||
try { wasapi?.Dispose(); } catch { /* ignore */ }
|
||||
try { device?.Dispose(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void DisposeOutput(OutputEntry o)
|
||||
{
|
||||
try { o.Output.Stop(); } catch { /* ignore */ }
|
||||
try { o.Output.Dispose(); } catch { /* ignore */ }
|
||||
try { o.Device.Dispose(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
private async Task ProduceLoop(CancellationToken ct)
|
||||
{
|
||||
// Pro Audio MMCSS for the producer thread — it's the one feeding all WASAPI outputs.
|
||||
using var threadBoost = new WindowsAudioThreadBoost("Pro Audio");
|
||||
|
||||
var ticksPerFrame = Stopwatch.Frequency * FrameMs / 1000;
|
||||
var nextTickStopwatch = Stopwatch.GetTimestamp() + ticksPerFrame;
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
if (nextTickStopwatch > now)
|
||||
{
|
||||
var sleepMs = (int)Math.Clamp((nextTickStopwatch - now) * 1000 / Stopwatch.Frequency, 1, 50);
|
||||
if (WaitHandle.WaitAny(new[] { ct.WaitHandle }, sleepMs) == 0) break;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (now - nextTickStopwatch > ticksPerFrame * 4)
|
||||
{
|
||||
nextTickStopwatch = now;
|
||||
}
|
||||
nextTickStopwatch += ticksPerFrame;
|
||||
|
||||
// Snapshot the buffers under the gate so we don't iterate a mid-mutation dict.
|
||||
// Also skip the source.Read entirely when no outputs are ticked: in
|
||||
// BothIndependent mode the source is a FanOutSource view shared with the ASIO
|
||||
// lane, and pulling here when WASAPI has nothing ticked makes the FanOut
|
||||
// consume PlayoutEngine audio ~10 ms ahead of the ASIO consumer, leaving the
|
||||
// ASIO lane permanently reading from a cache 10 ms behind the source. That
|
||||
// showed up in test logs as fanCacheMs sustained at 12–14 ms with bufAvg=0,
|
||||
// and audibly as an extra 10 ms baked into the ASIO lane's perceived latency.
|
||||
// The gate-then-read order matters; the previous order (read first, then
|
||||
// check outputs.Count) was the bug.
|
||||
BufferedWaveProvider[] targets;
|
||||
lock (gate)
|
||||
{
|
||||
if (outputs.Count == 0) continue;
|
||||
targets = outputs.Values.Select(o => o.Buffer).ToArray();
|
||||
}
|
||||
|
||||
var produced = source.Read(frameScratch, 0, FrameBytes);
|
||||
if (produced <= 0) continue;
|
||||
|
||||
foreach (var buffer in targets)
|
||||
{
|
||||
try { buffer.AddSamples(frameScratch, 0, produced); }
|
||||
catch { /* per-output failure shouldn't kill the loop */ }
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
onDiagnostic?.Invoke($"producer loop error: {ex.GetType().Name}: {ex.Message}");
|
||||
await Task.Delay(50, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class OutputEntry
|
||||
{
|
||||
public required MMDevice Device { get; init; }
|
||||
public required WasapiOut Output { get; init; }
|
||||
public required BufferedWaveProvider Buffer { get; init; }
|
||||
public required string Name { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the UDP receive socket and a single dedicated foreground thread that drains it.
|
||||
/// Hands raw packets (byte buffer + length + remote endpoint) up to a callback supplied by the
|
||||
/// owner — has no idea what's inside the packets.
|
||||
///
|
||||
/// Allocation-free in steady state: one fixed receive buffer reused across calls,
|
||||
/// <see cref="Socket.ReceiveFrom"/> with <see cref="SocketAddress"/> avoids the per-call
|
||||
/// IPEndPoint boxing that <see cref="UdpClient.ReceiveAsync"/> incurred.
|
||||
/// </summary>
|
||||
internal sealed class NetworkListener : IDisposable
|
||||
{
|
||||
private readonly Action<byte[], int, IPEndPoint> onPacket;
|
||||
private readonly Action<string> onDiagnostic;
|
||||
private CancellationTokenSource? cts;
|
||||
private Socket? socket;
|
||||
private Thread? thread;
|
||||
|
||||
// Time-in-user-handler instrumentation. We measure from "ReceiveFrom returned" to
|
||||
// "onPacket returned" so the SNAP can split observed inter-packet jitter at the
|
||||
// receiver. If this metric is consistently in the multi-ms range, the receiver's
|
||||
// own processing chain is the source of the gap (lock contention with the audio
|
||||
// thread, GC, decode work backing up) rather than the network or the sender.
|
||||
private long maxOnPacketTicks;
|
||||
public int TakeMaxOnPacketMs() =>
|
||||
(int)(Interlocked.Exchange(ref maxOnPacketTicks, 0) * 1000 / Stopwatch.Frequency);
|
||||
|
||||
public NetworkListener(Action<byte[], int, IPEndPoint> onPacket, Action<string> onDiagnostic)
|
||||
{
|
||||
this.onPacket = onPacket;
|
||||
this.onDiagnostic = onDiagnostic;
|
||||
}
|
||||
|
||||
public bool IsRunning => socket is not null;
|
||||
|
||||
public void Start(int udpPort)
|
||||
{
|
||||
Stop();
|
||||
cts = new CancellationTokenSource();
|
||||
socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
|
||||
socket.ReceiveBufferSize = 512 * 1024;
|
||||
socket.Bind(new IPEndPoint(IPAddress.Any, udpPort));
|
||||
|
||||
var startedSocket = socket;
|
||||
var token = cts.Token;
|
||||
thread = new Thread(() => ReceiveLoop(startedSocket, token))
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "RemSound.Receive",
|
||||
};
|
||||
thread.Start();
|
||||
onDiagnostic($"network listener bound to UDP :{udpPort}");
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
cts?.Cancel();
|
||||
try { socket?.Close(); } catch { /* ignore */ }
|
||||
socket = null;
|
||||
try { thread?.Join(500); } catch { /* ignore */ }
|
||||
thread = null;
|
||||
cts?.Dispose();
|
||||
cts = null;
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
private void ReceiveLoop(Socket activeSocket, CancellationToken token)
|
||||
{
|
||||
using var threadBoost = new WindowsAudioThreadBoost("Capture");
|
||||
var buffer = new byte[2048];
|
||||
EndPoint anyEndpoint = new IPEndPoint(IPAddress.Any, 0);
|
||||
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
int received;
|
||||
try
|
||||
{
|
||||
received = activeSocket.ReceiveFrom(buffer, 0, buffer.Length, SocketFlags.None, ref anyEndpoint);
|
||||
}
|
||||
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.Interrupted) { break; }
|
||||
catch (ObjectDisposedException) { break; }
|
||||
catch (SocketException) { continue; }
|
||||
catch (OperationCanceledException) { break; }
|
||||
|
||||
if (received <= 0) continue;
|
||||
if (anyEndpoint is not IPEndPoint remote) continue;
|
||||
|
||||
try
|
||||
{
|
||||
// Dispatch timing feeds the SNAP's rxDispMs column. Skipped when diagnostics
|
||||
// are off so the receive loop isn't paying two Stopwatch reads + a CAS loop
|
||||
// per packet for a number nobody is going to log.
|
||||
if (RemSound.Core.DiagnosticsGate.Enabled)
|
||||
{
|
||||
var dispatchStart = Stopwatch.GetTimestamp();
|
||||
onPacket(buffer, received, remote);
|
||||
var elapsed = Stopwatch.GetTimestamp() - dispatchStart;
|
||||
long current;
|
||||
do { current = Volatile.Read(ref maxOnPacketTicks); }
|
||||
while (elapsed > current && Interlocked.CompareExchange(ref maxOnPacketTicks, elapsed, current) != current);
|
||||
}
|
||||
else
|
||||
{
|
||||
onPacket(buffer, received, remote);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
onDiagnostic($"packet handler threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// Assembles multi-part PCM transport frames back into a single contiguous payload.
|
||||
/// PCM frames at 48 kHz × 24-bit × 2 ch × 10 ms = 2880 bytes, split into 2 UDP parts.
|
||||
///
|
||||
/// On a healthy LAN, parts arrive in order. If a part is missed we drop the whole frame
|
||||
/// rather than wait — at 10 ms cadence, waiting more than ~5 ms is worse than a single dropped frame.
|
||||
/// </summary>
|
||||
internal sealed class PcmFrameAssembler
|
||||
{
|
||||
private uint pendingFrameId;
|
||||
private byte pendingPartIndex; // index of the NEXT expected part
|
||||
private byte pendingTotalParts;
|
||||
private readonly byte[] assemblyBuffer = new byte[8192]; // largest reasonable PCM frame
|
||||
private int assemblyWritten;
|
||||
private long rejectionCount;
|
||||
private long discardedPartialCount;
|
||||
|
||||
/// <summary>
|
||||
/// Number of incoming parts that were rejected outright (out-of-order, malformed, overflow).
|
||||
/// Each rejection means at least one PCM frame's audio is lost.
|
||||
/// </summary>
|
||||
public long RejectionCount => Interlocked.Read(ref rejectionCount);
|
||||
|
||||
/// <summary>
|
||||
/// Number of partially-assembled frames discarded because a new frame started before the
|
||||
/// previous one's parts all arrived. Each discard means a half-finished frame's audio is lost.
|
||||
/// </summary>
|
||||
public long DiscardedPartialCount => Interlocked.Read(ref discardedPartialCount);
|
||||
|
||||
public bool TryAssemble(ReadOnlySpan<byte> partBytes, uint frameId, byte partIndex, byte totalParts, out ReadOnlySpan<byte> assembled)
|
||||
{
|
||||
assembled = default;
|
||||
|
||||
if (totalParts == 0)
|
||||
{
|
||||
Interlocked.Increment(ref rejectionCount);
|
||||
return false;
|
||||
}
|
||||
|
||||
// First part of a new frame? Start fresh, regardless of whether the previous one finished.
|
||||
if (partIndex == 0)
|
||||
{
|
||||
// If we had a partial frame waiting, count it as a discard — its audio is lost.
|
||||
if (pendingTotalParts != 0 && assemblyWritten > 0)
|
||||
{
|
||||
Interlocked.Increment(ref discardedPartialCount);
|
||||
}
|
||||
pendingFrameId = frameId;
|
||||
pendingPartIndex = 0;
|
||||
pendingTotalParts = totalParts;
|
||||
assemblyWritten = 0;
|
||||
}
|
||||
else if (frameId != pendingFrameId || partIndex != pendingPartIndex || totalParts != pendingTotalParts)
|
||||
{
|
||||
// Mismatch — we missed the start, or this is from a different frame. Discard.
|
||||
assemblyWritten = 0;
|
||||
pendingTotalParts = 0;
|
||||
Interlocked.Increment(ref rejectionCount);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (assemblyWritten + partBytes.Length > assemblyBuffer.Length)
|
||||
{
|
||||
// Frame larger than expected — defensive, should never happen with our packetization.
|
||||
assemblyWritten = 0;
|
||||
pendingTotalParts = 0;
|
||||
Interlocked.Increment(ref rejectionCount);
|
||||
return false;
|
||||
}
|
||||
|
||||
partBytes.CopyTo(assemblyBuffer.AsSpan(assemblyWritten));
|
||||
assemblyWritten += partBytes.Length;
|
||||
pendingPartIndex++;
|
||||
|
||||
if (pendingPartIndex == pendingTotalParts)
|
||||
{
|
||||
assembled = assemblyBuffer.AsSpan(0, assemblyWritten);
|
||||
// Reset for next frame after the caller consumes.
|
||||
pendingTotalParts = 0;
|
||||
assemblyWritten = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
pendingFrameId = 0;
|
||||
pendingPartIndex = 0;
|
||||
pendingTotalParts = 0;
|
||||
assemblyWritten = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
using System.Net;
|
||||
using NAudio.Wave;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// Multi-source playout coordinator. Holds one <see cref="SessionPlayout"/> per active sender and
|
||||
/// implements <see cref="IWaveProvider"/> by reading from all of them per WASAPI render callback
|
||||
/// and summing into the output buffer. Volume / mute / clipping live here; per-session adaptive
|
||||
/// rate lives inside each SessionPlayout.
|
||||
///
|
||||
/// Why multi-source: the previous design supported only one sender at a time and reset the
|
||||
/// playout buffer on every endpoint change. With two senders simultaneously sending to the same
|
||||
/// receiver (e.g. a peer and your own loopback for monitoring), Format packets arrived alternately
|
||||
/// from each endpoint and the buffer was flushed several times per second — horrible crackle.
|
||||
/// Now each sender owns its own buffer + drift corrector, and the mix bus sums them.
|
||||
///
|
||||
/// Concurrent-modification safety: <see cref="GetOrCreateSession"/> / <see cref="RemoveSession"/>
|
||||
/// take a lock and mutate the dictionary; <see cref="Read"/> snapshots the current values list
|
||||
/// (no allocation in steady state once the snapshot array has stabilised) before iterating, so it
|
||||
/// never iterates a mid-mutation collection. The per-session Read is lock-free.
|
||||
/// </summary>
|
||||
internal sealed class PlayoutEngine : IWaveProvider
|
||||
{
|
||||
private const int MixSampleRate = 48000;
|
||||
private const int MixChannels = 2;
|
||||
private const int MixBytesPerFrame = MixChannels * sizeof(float);
|
||||
private const int MixBytesPerSecond = MixSampleRate * MixBytesPerFrame;
|
||||
|
||||
// Soft-limiter parameters. Below the threshold, samples pass through untouched. Above it,
|
||||
// a tanh-based soft-knee smoothly compresses excess so the output asymptotes to ±1 without
|
||||
// ever clipping hard. This replaces the previous straight `Math.Clamp(-1, +1)` which slams
|
||||
// peaks into a square wave on transient summation. Standard pattern in audio mixers — see
|
||||
// research notes on conferencing-mixer clipping (NetEQ uses similar; PJSIP / RTP mixers too).
|
||||
private const float LimiterThreshold = 0.9f;
|
||||
private const float LimiterKnee = 1.0f - LimiterThreshold;
|
||||
|
||||
private readonly ReceiverDiagnostics diagnostics;
|
||||
private readonly object sessionsLock = new();
|
||||
// Sessions are keyed by (Endpoint, StreamId) — 2026-05-11. One peer can produce
|
||||
// multiple simultaneous streams (e.g. WASAPI lane + ASIO lane in the native-
|
||||
// independent audio mode). For the existing single-lane modes (WasapiOnly / AsioOnly /
|
||||
// Both) the sender emits a single streamId so the dict still has one entry per peer,
|
||||
// identical to the pre-refactor behaviour. The new mode adds a second entry per peer.
|
||||
private readonly Dictionary<(IPEndPoint Endpoint, ushort StreamId), SessionPlayout> sessions = new();
|
||||
private SessionPlayout[] sessionsSnapshot = [];
|
||||
// Per-route scratch. Each IWaveProvider surface (Mixed / WasapiLane / AsioLane) runs on
|
||||
// its own consumer thread in BothIndependent mode (WASAPI master producer + ASIO render
|
||||
// thread, independent). They must not share scratch arrays — concurrent writes would
|
||||
// garble output. The Mixed-route scratch keeps the original field names because that's
|
||||
// what the legacy Read still uses; the lane-route surfaces own their own copies.
|
||||
private float[] mixScratch = new float[8192];
|
||||
private float[] sessionScratch = new float[8192];
|
||||
private readonly LaneOutput wasapiLaneOutput;
|
||||
private readonly LaneOutput asioLaneOutput;
|
||||
|
||||
// Per-route latency state. Stage 4.5 (2026-05-11): added so BothIndependent mode can run
|
||||
// each lane at its own target/max without one lane's auto-tune dragging the other up.
|
||||
// In classic modes only the Mixed route is ever read from; the others sit at defaults
|
||||
// and consume no resources. Each LaneLatency's fields are volatile so UI-thread writes
|
||||
// are visible to the audio render thread without locks.
|
||||
private sealed class LaneLatency
|
||||
{
|
||||
public volatile int TargetMs = 30;
|
||||
public volatile int MaxMs = 80;
|
||||
}
|
||||
private readonly LaneLatency mixedLatency = new();
|
||||
private readonly LaneLatency wasapiLaneLatency = new();
|
||||
private readonly LaneLatency asioLaneLatency = new();
|
||||
private volatile bool muted;
|
||||
private volatile float volume = 1f;
|
||||
// 1 = stupid aggressive, 10 = perfectly smooth. Read on the audio thread, written from UI.
|
||||
// Now mostly a safety-knob for the click-trim catastrophic path; in normal operation the
|
||||
// Phase-2 drift corrector (in SessionPlayout) keeps the buffer near target so the trim
|
||||
// never fires regardless of this value.
|
||||
private volatile int smoothness = 3;
|
||||
// User-pickable artifact for underrun gaps. Stored as raw int because volatile doesn't
|
||||
// play with enum types directly. Push to existing SessionPlayouts on change so already-
|
||||
// running streams pick the new artifact up on the very next gap.
|
||||
private volatile int concealmentArtifactRaw = (int)ConcealmentArtifact.NoiseBurst;
|
||||
|
||||
public void SetSmoothness(int value) => smoothness = Math.Clamp(value, 1, 10);
|
||||
|
||||
/// <summary>Sets the concealment artifact for every active session and for any future
|
||||
/// session created after this call. Live-updates: the next time a session sees an
|
||||
/// underrun, it uses the new artifact.</summary>
|
||||
public void SetConcealmentArtifact(ConcealmentArtifact artifact)
|
||||
{
|
||||
concealmentArtifactRaw = (int)artifact;
|
||||
var snap = sessionsSnapshot;
|
||||
foreach (var s in snap) s.SetConcealmentArtifact(artifact);
|
||||
}
|
||||
|
||||
public WaveFormat WaveFormat { get; } = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, MixChannels);
|
||||
|
||||
/// <summary>Legacy property returning the Mixed route's target. Used by code paths that
|
||||
/// don't care about per-route routing (every classic mode, plus diagnostics that report
|
||||
/// "the" target latency in non-BothIndependent setups).</summary>
|
||||
public int TargetLatencyMs => mixedLatency.TargetMs;
|
||||
/// <summary>Legacy property returning the Mixed route's max.</summary>
|
||||
public int MaxLatencyMs => mixedLatency.MaxMs;
|
||||
|
||||
/// <summary>Per-route target accessor. In BothIndependent the WASAPI and ASIO routes have
|
||||
/// independent targets so each lane can settle at its native latency without the other
|
||||
/// pulling it. In classic modes only Mixed is meaningful; the other two routes return
|
||||
/// their defaults.</summary>
|
||||
public int TargetLatencyMsFor(RenderRoute route) => LatencyFor(route).TargetMs;
|
||||
public int MaxLatencyMsFor(RenderRoute route) => LatencyFor(route).MaxMs;
|
||||
|
||||
private LaneLatency LatencyFor(RenderRoute route) => route switch
|
||||
{
|
||||
RenderRoute.WasapiLane => wasapiLaneLatency,
|
||||
RenderRoute.AsioLane => asioLaneLatency,
|
||||
_ => mixedLatency,
|
||||
};
|
||||
|
||||
/// <summary>Aggregate buffered ms across all active sessions. Used by the App's diagnostic
|
||||
/// snapshot row. Per-session levels are not currently exposed (single number is enough for
|
||||
/// the existing snapshot column; the auto-tune doesn't depend on it).</summary>
|
||||
public int CurrentBufferMs
|
||||
{
|
||||
get
|
||||
{
|
||||
var snap = sessionsSnapshot;
|
||||
if (snap.Length == 0) return 0;
|
||||
var totalBytes = 0;
|
||||
foreach (var s in snap) totalBytes += s.BufferedBytes;
|
||||
return totalBytes / MixBytesPerFrame * 1000 / MixSampleRate;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsArmed
|
||||
{
|
||||
get
|
||||
{
|
||||
var snap = sessionsSnapshot;
|
||||
foreach (var s in snap) if (s.IsArmed) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public float Volume
|
||||
{
|
||||
get => volume;
|
||||
set => volume = Math.Clamp(value, 0f, 1f);
|
||||
}
|
||||
|
||||
public bool IsMuted
|
||||
{
|
||||
get => muted;
|
||||
set => muted = value;
|
||||
}
|
||||
|
||||
public PlayoutEngine(ReceiverDiagnostics diagnostics)
|
||||
{
|
||||
this.diagnostics = diagnostics;
|
||||
wasapiLaneOutput = new LaneOutput(this, RenderRoute.WasapiLane);
|
||||
asioLaneOutput = new LaneOutput(this, RenderRoute.AsioLane);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IWaveProvider surface for sessions tagged <see cref="RenderRoute.WasapiLane"/>. Only
|
||||
/// used in BothIndependent mode where the WASAPI render backend reads its own lane
|
||||
/// independently of the ASIO render. In the three classic modes (WasapiOnly / AsioOnly /
|
||||
/// Both) nothing ever reads from this surface and no session is ever tagged WasapiLane,
|
||||
/// so it returns silence and consumes no resources.
|
||||
/// </summary>
|
||||
public IWaveProvider WasapiLaneOutput => wasapiLaneOutput;
|
||||
|
||||
/// <summary>
|
||||
/// IWaveProvider surface for sessions tagged <see cref="RenderRoute.AsioLane"/>. Same
|
||||
/// contract as <see cref="WasapiLaneOutput"/>; only used in BothIndependent mode.
|
||||
/// </summary>
|
||||
public IWaveProvider AsioLaneOutput => asioLaneOutput;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the user's delay knob. Slider value drives the playout target directly so the change
|
||||
/// is audible immediately.
|
||||
///
|
||||
/// LOWER: by default disarms + drains every session. The buffer is now above the new target
|
||||
/// and has to actually shrink before playback resumes. Brief silence is unavoidable on this
|
||||
/// path; the user is asking for tighter latency and accepting the cost. Set
|
||||
/// <paramref name="drainOnLower"/> = false to take the SOFT path instead — the buffer keeps
|
||||
/// playing and the drift corrector's adaptive gain ramps it down over a few seconds. Used
|
||||
/// for auto-tune-driven lowers, where the user didn't ask for an immediate change and
|
||||
/// shouldn't hear one.
|
||||
///
|
||||
/// RAISE (2026-05-06 change): NO disarm, NO drain regardless of <paramref name="drainOnLower"/>.
|
||||
/// The buffer is now below the new target but audio keeps playing — the drift corrector's
|
||||
/// adaptive-gain term (see SessionPlayout) ramps the buffer up to the new target within
|
||||
/// seconds without the user ever hearing silence. Previously every raise produced an
|
||||
/// audible stop-start because the always-drain path blew the buffer away. Tweaking the
|
||||
/// slider in tiny increments is now silent.
|
||||
///
|
||||
/// Equal value: no-op.
|
||||
/// </summary>
|
||||
/// <summary>Legacy single-route setter — operates on the Mixed route. Every classic-mode
|
||||
/// call site continues to use this and behaves identically to pre-2026-05-11.</summary>
|
||||
public void SetMaxLatencyMs(int value, bool drainOnLower = true) =>
|
||||
SetMaxLatencyMs(RenderRoute.Mixed, value, drainOnLower);
|
||||
|
||||
/// <summary>
|
||||
/// Per-route setter. Identical algorithm to the legacy one but only drains sessions
|
||||
/// tagged with the matching route — so lowering the WASAPI lane's target won't disarm
|
||||
/// the ASIO lane's session (and vice versa). In BothIndependent the WASAPI/ASIO routes
|
||||
/// have their own slider in the UI driving each call.
|
||||
/// </summary>
|
||||
public void SetMaxLatencyMs(RenderRoute route, int value, bool drainOnLower = true)
|
||||
{
|
||||
var clamped = Math.Clamp(value, 1, 500);
|
||||
var lane = LatencyFor(route);
|
||||
var previousTarget = lane.TargetMs;
|
||||
lane.MaxMs = clamped;
|
||||
lane.TargetMs = clamped;
|
||||
if (clamped < previousTarget && drainOnLower)
|
||||
{
|
||||
// Only drain sessions on THIS route — leaves other-route sessions playing.
|
||||
var snap = sessionsSnapshot;
|
||||
foreach (var s in snap)
|
||||
{
|
||||
if (s.Route != route) continue;
|
||||
s.DisarmAndRequestDrain();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public SessionPlayout GetOrCreateSession(IPEndPoint endpoint, ushort streamId, int capacityBytes)
|
||||
{
|
||||
var key = (endpoint, streamId);
|
||||
lock (sessionsLock)
|
||||
{
|
||||
if (!sessions.TryGetValue(key, out var sp))
|
||||
{
|
||||
sp = new SessionPlayout(endpoint, streamId, capacityBytes);
|
||||
// Inherit the engine-wide artifact selection so a session created mid-stream
|
||||
// gets the right artifact from frame zero (rather than the SessionPlayout
|
||||
// default, which would only get overridden on the next SetConcealmentArtifact).
|
||||
sp.SetConcealmentArtifact((ConcealmentArtifact)concealmentArtifactRaw);
|
||||
sessions[key] = sp;
|
||||
sessionsSnapshot = sessions.Values.ToArray();
|
||||
}
|
||||
return sp;
|
||||
}
|
||||
}
|
||||
|
||||
public bool RemoveSession(IPEndPoint endpoint, ushort streamId)
|
||||
{
|
||||
var key = (endpoint, streamId);
|
||||
lock (sessionsLock)
|
||||
{
|
||||
if (sessions.Remove(key, out var sp))
|
||||
{
|
||||
sp.Dispose();
|
||||
sessionsSnapshot = sessions.Values.ToArray();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<SessionPlayout> ActiveSessions
|
||||
{
|
||||
get { lock (sessionsLock) return sessions.Values.ToList(); }
|
||||
}
|
||||
|
||||
public void ResetAll()
|
||||
{
|
||||
lock (sessionsLock)
|
||||
{
|
||||
foreach (var s in sessions.Values) s.Dispose();
|
||||
sessions.Clear();
|
||||
sessionsSnapshot = [];
|
||||
}
|
||||
}
|
||||
|
||||
public long AggregateUnderruns
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var s in sessionsSnapshot) total += s.UnderrunCount;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Per-route underrun aggregator. The continuous auto-tune uses this in
|
||||
/// BothIndependent mode so the WASAPI lane's underruns don't make the ASIO auto-tune
|
||||
/// skip a tick (and vice versa). In classic modes only the Mixed route has sessions,
|
||||
/// so AggregateUnderrunsFor(Mixed) == AggregateUnderruns.</summary>
|
||||
public long AggregateUnderrunsFor(RenderRoute route)
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var s in sessionsSnapshot)
|
||||
{
|
||||
if (s.Route == route) total += s.UnderrunCount;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>True if at least one session is currently tagged for this route. Used by
|
||||
/// the auto-tune to skip ticking a lane that has nobody to tune — without this gate
|
||||
/// the ASIO auto-tune (for example) would react to the shared network-gap signal
|
||||
/// populated by WASAPI-lane traffic and silently inflate its own target before the
|
||||
/// user has even started an ASIO source, so the next time ASIO actually goes live the
|
||||
/// receiver would already be pre-loaded with a high target.</summary>
|
||||
public bool HasSessionsForRoute(RenderRoute route)
|
||||
{
|
||||
foreach (var s in sessionsSnapshot)
|
||||
{
|
||||
if (s.Route == route) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public long AggregateDrops
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var s in sessionsSnapshot) total += s.DropCount;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sum of click-trim drop bytes across all active sessions.</summary>
|
||||
public long AggregateTrimDropBytes
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var s in sessionsSnapshot) total += s.TrimDropBytes;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sum of slider-drain drop bytes across all active sessions.</summary>
|
||||
public long AggregateDrainDropBytes
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var s in sessionsSnapshot) total += s.DrainDropBytes;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Total click-trim fires (one per trim event, regardless of bytes dropped).</summary>
|
||||
public long AggregateTrimFireCount
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var s in sessionsSnapshot) total += s.TrimFireCount;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cumulative count of single-frame drops the Phase-2 drift corrector has applied.</summary>
|
||||
public long AggregateDriftDropFrames
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var s in sessionsSnapshot) total += s.DriftDropFramesTotal;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cumulative count of single-frame repeats the Phase-2 drift corrector has applied.</summary>
|
||||
public long AggregateDriftRepeatFrames
|
||||
{
|
||||
get
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var s in sessionsSnapshot) total += s.DriftRepeatFramesTotal;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
// === WASAPI render thread ===
|
||||
|
||||
/// <summary>
|
||||
/// Render-side audio pull. Iterates every session regardless of lane tag and sums them
|
||||
/// into one mixed bus. This is what every render backend (WasapiOnly, AsioOnly, classic
|
||||
/// Both via the tee, and BothIndependent via the tee) reads from, so a user can pick any
|
||||
/// output device for any received audio — independently of which capture technology the
|
||||
/// sender used. Per-lane latency targets are still honoured: each session reads its own
|
||||
/// route's TargetMs / MaxMs via <see cref="LatencyFor"/>, so the WASAPI-captured stream
|
||||
/// can buffer at one latency and the ASIO-captured stream at another within the same
|
||||
/// output mix. The lane-specific <see cref="WasapiLaneOutput"/> / <see cref="AsioLaneOutput"/>
|
||||
/// surfaces are kept around for future per-route routing options but are not used by the
|
||||
/// default render path (see <c>CompositeRenderBackend</c>). 2026-05-11 revision: previous
|
||||
/// implementation filtered by route, which made it impossible to route a WASAPI-captured
|
||||
/// stream onto an ASIO output (and vice versa) in BothIndependent mode — that broke a
|
||||
/// long-standing cross-backend send/receive flow.
|
||||
/// </summary>
|
||||
public int Read(byte[] buffer, int offset, int count) =>
|
||||
ReadAllSessions(buffer, offset, count, mixScratch, sessionScratch, recordDiagnostics: true);
|
||||
|
||||
/// <summary>
|
||||
/// Shared per-route render pull. Iterates the session snapshot, summing only those
|
||||
/// sessions whose <see cref="SessionPlayout.Route"/> matches the requested filter into
|
||||
/// the caller's scratch buffers, applies volume/mute/limiter, and packs to bytes. The
|
||||
/// Mixed route additionally feeds <see cref="ReceiverDiagnostics"/> (output-step + buffer
|
||||
/// level) — lane routes skip diagnostics to avoid double-counting in BothIndependent mode
|
||||
/// where both lanes run their own ReadForRoute concurrently and the legacy single
|
||||
/// per-tick stats columns are still the user-visible source of truth.
|
||||
/// </summary>
|
||||
internal int ReadForRoute(byte[] buffer, int offset, int count, RenderRoute route, float[] mixBuf, float[] sessionBuf, bool recordDiagnostics)
|
||||
{
|
||||
if (recordDiagnostics) diagnostics.RecordRenderRead(count);
|
||||
|
||||
var outFrames = count / MixBytesPerFrame;
|
||||
var outFloats = outFrames * MixChannels;
|
||||
// Each route owns its scratch buffers; grow them in place if the consumer is asking
|
||||
// for a bigger block than we've ever served before. Per-route ownership means the
|
||||
// BothIndependent threads don't fight over one buffer.
|
||||
if (mixBuf.Length < outFloats || sessionBuf.Length < outFloats)
|
||||
{
|
||||
(mixBuf, sessionBuf) = GrowScratch(route, outFloats);
|
||||
}
|
||||
Array.Clear(mixBuf, 0, outFloats);
|
||||
|
||||
// Snapshot — local copy so the iteration is safe against concurrent dict mutations.
|
||||
var snap = sessionsSnapshot;
|
||||
|
||||
// Pull this route's target/max from the per-route state. In Mixed (classic modes)
|
||||
// this reads mixedLatency, identical to pre-Stage-4.5 behaviour. In BothIndependent
|
||||
// the WASAPI and ASIO route reads pick up their respective LaneLatency entries so
|
||||
// each lane's session is paced against its own slider value.
|
||||
var routeLatency = LatencyFor(route);
|
||||
var routeTargetMs = routeLatency.TargetMs;
|
||||
var routeMaxMs = routeLatency.MaxMs;
|
||||
|
||||
var aggregateBufferedBytes = 0;
|
||||
var anyContributed = false;
|
||||
foreach (var session in snap)
|
||||
{
|
||||
if (session.Route != route) continue;
|
||||
aggregateBufferedBytes += session.BufferedBytes;
|
||||
var produced = session.ReadFloats(sessionBuf.AsSpan(0, outFloats), outFrames, routeTargetMs, routeMaxMs, smoothness);
|
||||
if (produced <= 0) continue;
|
||||
anyContributed = true;
|
||||
var summed = produced * MixChannels;
|
||||
for (var i = 0; i < summed; i++)
|
||||
{
|
||||
mixBuf[i] += sessionBuf[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (recordDiagnostics) diagnostics.RecordBufferLevel(aggregateBufferedBytes);
|
||||
|
||||
if (!anyContributed)
|
||||
{
|
||||
Array.Clear(buffer, offset, count);
|
||||
return count;
|
||||
}
|
||||
|
||||
// Apply volume / mute and the soft-knee tanh limiter before packing. Both the volume
|
||||
// knob and the limiter are receiver-engine-wide concerns, so they apply equally to
|
||||
// every route (matching the principle that a single user-set volume affects every
|
||||
// output device regardless of which lane it belongs to).
|
||||
var localVolume = muted ? 0f : volume;
|
||||
for (var i = 0; i < outFloats; i++)
|
||||
{
|
||||
var v = mixBuf[i] * localVolume;
|
||||
var sign = v < 0f ? -1f : 1f;
|
||||
var abs = v * sign;
|
||||
if (abs > LimiterThreshold)
|
||||
{
|
||||
var excess = abs - LimiterThreshold;
|
||||
var compressed = LimiterKnee * MathF.Tanh(excess / LimiterKnee);
|
||||
v = sign * (LimiterThreshold + compressed);
|
||||
}
|
||||
mixBuf[i] = v;
|
||||
}
|
||||
|
||||
if (recordDiagnostics) diagnostics.RecordOutputSampleSteps(mixBuf.AsSpan(0, outFloats));
|
||||
|
||||
Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float));
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read all sessions, regardless of lane tag, into a single mixed bus. Each session is
|
||||
/// paced against ITS OWN lane's target/max latency, so a WASAPI-captured session and an
|
||||
/// ASIO-captured session in BothIndependent mode each maintain their independent buffer
|
||||
/// depths even though they end up in the same output mix. This is the path every render
|
||||
/// backend reads from in normal operation — the lane surfaces above are kept for
|
||||
/// potential per-output-device routing in a future revision but are not used today.
|
||||
/// </summary>
|
||||
private int ReadAllSessions(byte[] buffer, int offset, int count, float[] mixBuf, float[] sessionBuf, bool recordDiagnostics)
|
||||
{
|
||||
if (recordDiagnostics) diagnostics.RecordRenderRead(count);
|
||||
|
||||
var outFrames = count / MixBytesPerFrame;
|
||||
var outFloats = outFrames * MixChannels;
|
||||
if (mixBuf.Length < outFloats || sessionBuf.Length < outFloats)
|
||||
{
|
||||
(mixBuf, sessionBuf) = GrowScratch(RenderRoute.Mixed, outFloats);
|
||||
}
|
||||
Array.Clear(mixBuf, 0, outFloats);
|
||||
|
||||
var snap = sessionsSnapshot;
|
||||
var aggregateBufferedBytes = 0;
|
||||
var anyContributed = false;
|
||||
foreach (var session in snap)
|
||||
{
|
||||
// Per-session latency: each session's own lane governs its buffer behaviour, so a
|
||||
// WASAPI-captured stream can sit at one target depth and an ASIO-captured stream
|
||||
// at another. Mixing them at the output level doesn't collapse those targets.
|
||||
var laneLatency = LatencyFor(session.Route);
|
||||
aggregateBufferedBytes += session.BufferedBytes;
|
||||
var produced = session.ReadFloats(sessionBuf.AsSpan(0, outFloats), outFrames, laneLatency.TargetMs, laneLatency.MaxMs, smoothness);
|
||||
if (produced <= 0) continue;
|
||||
anyContributed = true;
|
||||
var summed = produced * MixChannels;
|
||||
for (var i = 0; i < summed; i++)
|
||||
{
|
||||
mixBuf[i] += sessionBuf[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (recordDiagnostics) diagnostics.RecordBufferLevel(aggregateBufferedBytes);
|
||||
|
||||
if (!anyContributed)
|
||||
{
|
||||
Array.Clear(buffer, offset, count);
|
||||
return count;
|
||||
}
|
||||
|
||||
var localVolume = muted ? 0f : volume;
|
||||
for (var i = 0; i < outFloats; i++)
|
||||
{
|
||||
var v = mixBuf[i] * localVolume;
|
||||
var sign = v < 0f ? -1f : 1f;
|
||||
var abs = v * sign;
|
||||
if (abs > LimiterThreshold)
|
||||
{
|
||||
var excess = abs - LimiterThreshold;
|
||||
var compressed = LimiterKnee * MathF.Tanh(excess / LimiterKnee);
|
||||
v = sign * (LimiterThreshold + compressed);
|
||||
}
|
||||
mixBuf[i] = v;
|
||||
}
|
||||
|
||||
if (recordDiagnostics) diagnostics.RecordOutputSampleSteps(mixBuf.AsSpan(0, outFloats));
|
||||
Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float));
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grow the per-route scratch buffers in place when a render backend asks for a bigger
|
||||
/// block than we've previously served. Writes the new arrays back to the route-owning
|
||||
/// fields (so subsequent reads from this route see the larger buffer) and returns them
|
||||
/// to the caller for use in the current Read. The Mixed route's buffers live on the
|
||||
/// PlayoutEngine itself for legacy reasons; the two lane routes own their buffers on
|
||||
/// the corresponding LaneOutput instance. Each route is single-threaded (one consumer
|
||||
/// per surface) so we don't need to lock around the realloc.
|
||||
/// </summary>
|
||||
private (float[] mix, float[] session) GrowScratch(RenderRoute route, int neededFloats)
|
||||
{
|
||||
switch (route)
|
||||
{
|
||||
case RenderRoute.Mixed:
|
||||
if (mixScratch.Length < neededFloats) mixScratch = new float[neededFloats];
|
||||
if (sessionScratch.Length < neededFloats) sessionScratch = new float[neededFloats];
|
||||
return (mixScratch, sessionScratch);
|
||||
case RenderRoute.WasapiLane:
|
||||
if (wasapiLaneOutput.MixScratch.Length < neededFloats) wasapiLaneOutput.MixScratch = new float[neededFloats];
|
||||
if (wasapiLaneOutput.SessionScratch.Length < neededFloats) wasapiLaneOutput.SessionScratch = new float[neededFloats];
|
||||
return (wasapiLaneOutput.MixScratch, wasapiLaneOutput.SessionScratch);
|
||||
case RenderRoute.AsioLane:
|
||||
if (asioLaneOutput.MixScratch.Length < neededFloats) asioLaneOutput.MixScratch = new float[neededFloats];
|
||||
if (asioLaneOutput.SessionScratch.Length < neededFloats) asioLaneOutput.SessionScratch = new float[neededFloats];
|
||||
return (asioLaneOutput.MixScratch, asioLaneOutput.SessionScratch);
|
||||
default:
|
||||
return (mixScratch, sessionScratch);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-lane IWaveProvider. Each instance filters PlayoutEngine's session snapshot down
|
||||
/// to sessions tagged with a specific <see cref="RenderRoute"/> and runs the standard
|
||||
/// volume/mute/limiter pipeline against just that subset. Only meaningful in
|
||||
/// BothIndependent mode; in classic modes nothing reads from these surfaces.
|
||||
/// </summary>
|
||||
private sealed class LaneOutput : IWaveProvider
|
||||
{
|
||||
private readonly PlayoutEngine owner;
|
||||
private readonly RenderRoute route;
|
||||
// Each lane owns its own scratch (fields exposed to the owner so ReadForRoute can
|
||||
// grow them via the same helper). Public-internal exposure rather than method-call
|
||||
// because the grow path needs a ref to the slot, and there's exactly one caller per
|
||||
// field — the owner. Keeping these private to the outer class via internal access.
|
||||
internal float[] MixScratch = new float[8192];
|
||||
internal float[] SessionScratch = new float[8192];
|
||||
|
||||
public WaveFormat WaveFormat => owner.WaveFormat;
|
||||
|
||||
public LaneOutput(PlayoutEngine owner, RenderRoute route)
|
||||
{
|
||||
this.owner = owner;
|
||||
this.route = route;
|
||||
}
|
||||
|
||||
public int Read(byte[] buffer, int offset, int count) =>
|
||||
owner.ReadForRoute(buffer, offset, count, route, MixScratch, SessionScratch, recordDiagnostics: false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// Sub-second telemetry that the App pulls once per second for the log file.
|
||||
/// Tracks rolling stats so a 1 Hz snapshot reveals what's actually happening
|
||||
/// at audio-rate resolution. All counters are interlocked or volatile so the
|
||||
/// network thread, render thread, and App thread can read/write without locks.
|
||||
///
|
||||
/// Naming convention:
|
||||
/// *PerSecond → reset on every second-boundary read
|
||||
/// *Rolling → averaged over the last second
|
||||
/// *Cumulative → since session start
|
||||
/// </summary>
|
||||
public sealed class ReceiverDiagnostics
|
||||
{
|
||||
// Network arrival timing.
|
||||
private long lastPacketTicks;
|
||||
private long maxArrivalGapTicks;
|
||||
private long packetCountSinceLastReport;
|
||||
|
||||
// Buffer sampling. Each Read call records the buffer level it observed.
|
||||
// We keep a tiny rolling window so the App can show min/avg/max for the last second.
|
||||
private long bufferSampleSumBytes;
|
||||
private int bufferSampleCount;
|
||||
private int bufferSampleMinBytes = int.MaxValue;
|
||||
private int bufferSampleMaxBytes;
|
||||
|
||||
// WASAPI render Read sizes.
|
||||
private int maxRenderReadBytes;
|
||||
private int renderReadCount;
|
||||
|
||||
// Render-callback timing — the parallel of sender's capture-callback gap. PlayoutEngine.Read
|
||||
// is invoked by the audio device's render callback (NAudio's WASAPI or ASIO output wrapper).
|
||||
// Healthy systems show sub-ms variance from a strict period (= ASIO buffer / sample rate, or
|
||||
// WASAPI engine period). Spikes here mean the audio output thread is being scheduled with
|
||||
// jitter — which manifests as audible discontinuities even when RemSound's playout buffer is
|
||||
// healthy, because the audio HARDWARE expects samples on a rigid clock and gets them late.
|
||||
// RemSound's "Underruns" counter measures whether RemSound's buffer ran dry; this measures
|
||||
// whether the audio device's own buffer was being fed punctually.
|
||||
private long lastRenderCallbackTicks;
|
||||
private long maxRenderCallbackGapTicks;
|
||||
|
||||
// Sample-step diagnostic. Two quantities:
|
||||
//
|
||||
// 1. maxSampleStep — the largest |sample[n] - sample[n-1]| in the diag window. Peak
|
||||
// indicator, prone to false positives on bright music. Kept for visibility.
|
||||
//
|
||||
// 2. spikeCount — adaptive second-derivative outlier detector. A click manifests as a
|
||||
// second derivative |s[i+1] - 2*s[i] + s[i-1]| that's anomalously large *relative to
|
||||
// its recent typical value*. Smooth music has consistent (low) second-derivative
|
||||
// energy. Bright music has consistent (medium) second-derivative energy. A click has
|
||||
// *suddenly* much larger second-derivative energy than recent norm, regardless of
|
||||
// overall content level.
|
||||
//
|
||||
// The detector tracks an EMA of |second derivative| over ~64 samples (~1.3 ms at 48 kHz)
|
||||
// and flags samples whose own second-derivative exceeds that EMA by a multiplier.
|
||||
// Multiplier of 5× = "this sample's discontinuity is 5× louder than the recent local
|
||||
// discontinuity baseline." Plus an absolute floor so quiet-content noise doesn't trip it.
|
||||
//
|
||||
// Behaviour by signal type:
|
||||
// - Silence: zero second derivative → spikeCount stays 0.
|
||||
// - Smooth tone: low, consistent 2nd derivative → ratio ~1 → spikeCount stays 0.
|
||||
// - Bright tone: high but consistent 2nd derivative → ratio ~1 → spikeCount stays 0.
|
||||
// - Click on top of any of the above: 2nd derivative spikes for one sample, ratio >> 5
|
||||
// → spikeCount increments by 1 per click sample.
|
||||
private const float SpikeEnvAlpha = 1f / 64f; // ~1.3 ms half-life at 48 kHz
|
||||
private const float SpikeRatioThreshold = 5.0f; // sample's 2nd-deriv must be 5× recent norm
|
||||
private const float SpikeAbsoluteFloor = 0.02f; // ignore near-silence noise
|
||||
private float maxSampleStep;
|
||||
private int spikeCount;
|
||||
private float secondDerivEMA; // running average of |2nd derivative|
|
||||
private bool spikeStateSeeded;
|
||||
private float prevPrevSample; // s[i-2] when computing s[i]
|
||||
// Last sample of the previous Read call — used as the seed for the first sample of the
|
||||
// next Read so step measurement spans Read boundaries (otherwise we'd miss clicks that
|
||||
// sit on the boundary between two Reads, which is exactly where buffer-edge clicks live).
|
||||
private float lastWrittenSample;
|
||||
private bool lastSampleSeeded;
|
||||
|
||||
public void RecordPacketArrived()
|
||||
{
|
||||
// Diagnostics-gate first. packetCountSinceLastReport feeds the SNAP diag line too so
|
||||
// it isn't worth the audio-thread cost to keep incrementing it when nobody is reading.
|
||||
if (!RemSound.Core.DiagnosticsGate.Enabled) return;
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
var prev = Interlocked.Exchange(ref lastPacketTicks, now);
|
||||
if (prev != 0)
|
||||
{
|
||||
var gap = now - prev;
|
||||
// Track max gap (lock-free max via CAS).
|
||||
long currentMax;
|
||||
do { currentMax = Volatile.Read(ref maxArrivalGapTicks); }
|
||||
while (gap > currentMax && Interlocked.CompareExchange(ref maxArrivalGapTicks, gap, currentMax) != currentMax);
|
||||
}
|
||||
Interlocked.Increment(ref packetCountSinceLastReport);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zeros the inter-packet and render-callback timestamps so the next sample taken doesn't
|
||||
/// measure a gap across a stream-session boundary. Called from <c>AudioReceiver</c>
|
||||
/// whenever a new <c>StreamSession</c> opens — without this, the first packet of the new
|
||||
/// session would record a gap equal to the entire idle duration between the previous
|
||||
/// session ending and this one starting (potentially tens of seconds), poisoning the
|
||||
/// auto-tune's recent-gap window and causing it to recommend an absurdly large latency
|
||||
/// target. The same applies to the render-callback timing: a new audio output device or
|
||||
/// re-opened ASIO driver should start its own gap measurement, not inherit one from the
|
||||
/// previous backend's last render.
|
||||
/// </summary>
|
||||
public void ResetGapMeasurements()
|
||||
{
|
||||
Interlocked.Exchange(ref lastPacketTicks, 0);
|
||||
Interlocked.Exchange(ref maxArrivalGapTicks, 0);
|
||||
Interlocked.Exchange(ref lastRenderCallbackTicks, 0);
|
||||
Interlocked.Exchange(ref maxRenderCallbackGapTicks, 0);
|
||||
}
|
||||
|
||||
public void RecordBufferLevel(int bufferedBytes)
|
||||
{
|
||||
if (!RemSound.Core.DiagnosticsGate.Enabled) return;
|
||||
Interlocked.Add(ref bufferSampleSumBytes, bufferedBytes);
|
||||
Interlocked.Increment(ref bufferSampleCount);
|
||||
// Track min/max via CAS.
|
||||
int curMin;
|
||||
do { curMin = Volatile.Read(ref bufferSampleMinBytes); }
|
||||
while (bufferedBytes < curMin && Interlocked.CompareExchange(ref bufferSampleMinBytes, bufferedBytes, curMin) != curMin);
|
||||
int curMax;
|
||||
do { curMax = Volatile.Read(ref bufferSampleMaxBytes); }
|
||||
while (bufferedBytes > curMax && Interlocked.CompareExchange(ref bufferSampleMaxBytes, bufferedBytes, curMax) != curMax);
|
||||
}
|
||||
|
||||
public void RecordRenderRead(int bytesRequested)
|
||||
{
|
||||
if (!RemSound.Core.DiagnosticsGate.Enabled) return;
|
||||
Interlocked.Increment(ref renderReadCount);
|
||||
int curMax;
|
||||
do { curMax = Volatile.Read(ref maxRenderReadBytes); }
|
||||
while (bytesRequested > curMax && Interlocked.CompareExchange(ref maxRenderReadBytes, bytesRequested, curMax) != curMax);
|
||||
|
||||
// Track the gap since the previous render callback. First call seeds the timestamp
|
||||
// without recording a gap (no prior reference). Lock-free max-update via CAS.
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
var prev = Interlocked.Exchange(ref lastRenderCallbackTicks, now);
|
||||
if (prev != 0)
|
||||
{
|
||||
var gap = now - prev;
|
||||
long currentMax;
|
||||
do { currentMax = Volatile.Read(ref maxRenderCallbackGapTicks); }
|
||||
while (gap > currentMax && Interlocked.CompareExchange(ref maxRenderCallbackGapTicks, gap, currentMax) != currentMax);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Scan a span of float samples that RemSound is about to hand to NAudio and
|
||||
/// record (a) the peak sample-to-sample step and (b) an adaptive count of second-
|
||||
/// derivative outliers — samples whose discontinuity is anomalously large relative to
|
||||
/// recent local norm. The latter is the click-specific signal: it's content-INVARIANT,
|
||||
/// triggering only when a sample really does break the local audio's predictability.</summary>
|
||||
public void RecordOutputSampleSteps(ReadOnlySpan<float> samples)
|
||||
{
|
||||
// Most expensive probe in the engine — per-sample second-derivative arithmetic on
|
||||
// every render block. Gate it at the top so the render thread doesn't pay any of
|
||||
// this when nobody is going to read the column.
|
||||
if (!RemSound.Core.DiagnosticsGate.Enabled) return;
|
||||
if (samples.IsEmpty) return;
|
||||
var localMax = maxSampleStep;
|
||||
var localSpikes = spikeCount;
|
||||
var prev = lastSampleSeeded ? lastWrittenSample : samples[0];
|
||||
var prevPrev = spikeStateSeeded ? prevPrevSample : prev;
|
||||
// Initial seed for the EMA on first-ever call: small positive value so the first
|
||||
// few samples can't all register as anomalies before the EMA has a chance to learn.
|
||||
var derivEMA = spikeStateSeeded ? secondDerivEMA : 0.01f;
|
||||
var oneMinusAlpha = 1f - SpikeEnvAlpha;
|
||||
for (var i = 0; i < samples.Length; i++)
|
||||
{
|
||||
var cur = samples[i];
|
||||
var step = cur - prev;
|
||||
if (step < 0) step = -step;
|
||||
if (step > localMax) localMax = step;
|
||||
|
||||
// Second derivative: |s[i] - 2*s[i-1] + s[i-2]|. Smooth audio has low and
|
||||
// consistent values; a click introduces a sudden large value at one sample.
|
||||
var d2 = cur - 2f * prev + prevPrev;
|
||||
if (d2 < 0f) d2 = -d2;
|
||||
// Spike detector: anomalously high second derivative relative to recent norm.
|
||||
// The absolute floor (0.02) prevents counting in near-silence where the EMA
|
||||
// is tiny and any small sample noise would technically exceed N× the EMA.
|
||||
// Math.Max ensures we don't divide-by-zero or trip on EMA close to 0.
|
||||
var dynamicThreshold = Math.Max(derivEMA * SpikeRatioThreshold, SpikeAbsoluteFloor);
|
||||
if (d2 > dynamicThreshold)
|
||||
{
|
||||
localSpikes++;
|
||||
// Update the EMA WITHOUT folding this anomaly in (so a click doesn't poison
|
||||
// the baseline and mask subsequent clicks). Re-feed the EMA with its current
|
||||
// value, effectively a no-op update on click samples.
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update EMA only on non-anomalous samples — keeps the baseline tracking
|
||||
// smooth audio character, not click events.
|
||||
derivEMA = derivEMA * oneMinusAlpha + d2 * SpikeEnvAlpha;
|
||||
}
|
||||
|
||||
prevPrev = prev;
|
||||
prev = cur;
|
||||
}
|
||||
maxSampleStep = localMax;
|
||||
spikeCount = localSpikes;
|
||||
secondDerivEMA = derivEMA;
|
||||
prevPrevSample = prevPrev;
|
||||
spikeStateSeeded = true;
|
||||
lastWrittenSample = prev;
|
||||
lastSampleSeeded = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot the rolling counters and reset them. Called by the App once per second.
|
||||
/// </summary>
|
||||
public DiagSnapshot Take(int mixBytesPerSecond)
|
||||
{
|
||||
var maxGapTicks = Interlocked.Exchange(ref maxArrivalGapTicks, 0);
|
||||
var pktCount = Interlocked.Exchange(ref packetCountSinceLastReport, 0);
|
||||
var sumBytes = Interlocked.Exchange(ref bufferSampleSumBytes, 0);
|
||||
var sampleCount = Interlocked.Exchange(ref bufferSampleCount, 0);
|
||||
var minBytes = Interlocked.Exchange(ref bufferSampleMinBytes, int.MaxValue);
|
||||
var maxBytes = Interlocked.Exchange(ref bufferSampleMaxBytes, 0);
|
||||
var maxReadBytes = Interlocked.Exchange(ref maxRenderReadBytes, 0);
|
||||
var readCount = Interlocked.Exchange(ref renderReadCount, 0);
|
||||
var maxRenderCbGap = Interlocked.Exchange(ref maxRenderCallbackGapTicks, 0);
|
||||
// Sample-step is read from the render thread (which is the only writer); diag thread
|
||||
// reads + zeroes. The reader sees a slightly stale value if a Read is in flight, which
|
||||
// is fine — values will fold into the next snapshot.
|
||||
var maxStep = maxSampleStep;
|
||||
maxSampleStep = 0f;
|
||||
var bigSteps = spikeCount;
|
||||
spikeCount = 0;
|
||||
|
||||
var ticksToMsScale = 1000.0 / Stopwatch.Frequency;
|
||||
return new DiagSnapshot(
|
||||
PacketCount: pktCount,
|
||||
MaxArrivalGapMs: (int)(maxGapTicks * ticksToMsScale),
|
||||
BufferAvgMs: sampleCount > 0 ? (int)(sumBytes / sampleCount * 1000.0 / mixBytesPerSecond) : 0,
|
||||
BufferMinMs: minBytes == int.MaxValue ? 0 : (int)(minBytes * 1000.0 / mixBytesPerSecond),
|
||||
BufferMaxMs: (int)(maxBytes * 1000.0 / mixBytesPerSecond),
|
||||
BufferSampleCount: sampleCount,
|
||||
MaxRenderReadMs: (int)(maxReadBytes * 1000.0 / mixBytesPerSecond),
|
||||
MaxRenderCallbackGapMs: (int)(maxRenderCbGap * ticksToMsScale),
|
||||
RenderReadCount: readCount,
|
||||
MaxOutputSampleStep: maxStep,
|
||||
EnvelopeSpikeCount: bigSteps);
|
||||
}
|
||||
|
||||
public readonly record struct DiagSnapshot(
|
||||
long PacketCount,
|
||||
int MaxArrivalGapMs,
|
||||
int BufferAvgMs,
|
||||
int BufferMinMs,
|
||||
int BufferMaxMs,
|
||||
int BufferSampleCount,
|
||||
int MaxRenderReadMs,
|
||||
int MaxRenderCallbackGapMs,
|
||||
int RenderReadCount,
|
||||
float MaxOutputSampleStep,
|
||||
int EnvelopeSpikeCount);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<RootNamespace>RemSound.Receiver</RootNamespace>
|
||||
<AssemblyName>RemSound.Receiver</AssemblyName>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\RemSound.Core\RemSound.Core.csproj" />
|
||||
<PackageReference Include="NAudio" Version="2.3.0" />
|
||||
<PackageReference Include="Concentus" Version="2.2.2" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,749 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// One incoming sender's playout state: its own SPSC ring buffer plus a small set of drift /
|
||||
/// concealment / smoothness state. Each remote endpoint that's actively sending audio gets
|
||||
/// exactly one SessionPlayout. <see cref="PlayoutEngine"/> owns the collection and reads from
|
||||
/// all of them per render callback, summing into the mix bus.
|
||||
///
|
||||
/// Drift correction: each sender has its own audio crystal that runs at slightly different
|
||||
/// rate from the receiver's. This class compensates with a slow integrator that drops or
|
||||
/// repeats one stereo frame at a time when sustained drift is detected, with a short cosine
|
||||
/// crossfade across each splice for inaudibility. See <c>DriftGain</c> / <c>DriftCrossfadeFrames</c>.
|
||||
///
|
||||
/// Threading: <see cref="Write"/> runs on the network thread (per-sender producer);
|
||||
/// <see cref="ReadFloats"/> runs on the WASAPI/ASIO render thread (single consumer). The
|
||||
/// AudioRingBuffer is SPSC-safe; drift / concealment state is only touched from the consumer.
|
||||
/// </summary>
|
||||
internal sealed class SessionPlayout : IDisposable
|
||||
{
|
||||
private const int MixSampleRate = 48000;
|
||||
private const int MixChannels = 2;
|
||||
private const int MixBytesPerFrame = MixChannels * sizeof(float);
|
||||
private const int MixBytesPerSecond = MixSampleRate * MixBytesPerFrame;
|
||||
|
||||
private readonly AudioRingBuffer playout;
|
||||
// Scratch buffer used by the drift-correction crossfade path. Sized as needed inside
|
||||
// ReadFloats; persistent here so we don't reallocate per call.
|
||||
private float[] driftScratch = new float[8192];
|
||||
|
||||
private volatile bool playbackArmed;
|
||||
private volatile bool drainRequested;
|
||||
|
||||
// Tracks the largest single Write's audio duration in ms — i.e. the active codec's
|
||||
// packet-frame size as observed at the buffer level. Used to floor the click-trim
|
||||
// margin so we don't false-trim during the natural sawtooth caused by packet
|
||||
// arrival (each packet bumps the buffer by frame-ms, then render drains it down).
|
||||
// Updated from the network thread; read from the audio thread. Volatile is enough
|
||||
// because we only ever monotonically increase it within a session lifetime.
|
||||
private volatile int largestWriteMs;
|
||||
|
||||
// === Drop-cause split ===
|
||||
// Codex pointed out that the legacy `DropCount` on the ring buffer rolled up every reason
|
||||
// we ever dropped audio bytes, making "Drops" in the diag opaque. These per-cause counters
|
||||
// let the diag log distinguish:
|
||||
// * trim drops — smoothness-knob click-trim trimming the buffer toward target
|
||||
// * drain drops — one-shot drain when the user moves the latency slider
|
||||
// * catastrophic — TrimFromProducer when the buffer crosses the 1s safety cap
|
||||
// (Ring-buffer overflow on Write is still counted in playout.DropCount; we expose that
|
||||
// separately.) Each counter is in BYTES so the magnitudes are comparable.
|
||||
private long trimDropBytes;
|
||||
private long drainDropBytes;
|
||||
// Separate count of how many TIMES the click-trim fired (a tiny number tells us frequency,
|
||||
// independent of the byte amount).
|
||||
private long trimFireCount;
|
||||
|
||||
// === Underrun concealment state ===
|
||||
// When the playout ring buffer comes up short on a render-side read, AudioRingBuffer
|
||||
// silence-fills the missing portion with hard zero. The transient from the last real
|
||||
// sample (amplitude X) to instant zero produces an audible click, especially on PCM
|
||||
// (Opus has its own decoder-side PLC for packet loss but doesn't help with audio-thread
|
||||
// starvation). We replace that hard zero with a brief envelope from the last real sample
|
||||
// down to silence, and a matching envelope back up when audio resumes. The buffer is still
|
||||
// silent during a sustained underrun — but the *edges* are smooth, which is where the
|
||||
// human ear hears the click. ConcealFadeFramesShort at 32 = ~0.67 ms at 48 kHz.
|
||||
//
|
||||
// The artifact character is user-pickable (cosine tone short / cosine tone low / noise
|
||||
// burst / raw click). Each option uses the same edge-smoothing principle but a different
|
||||
// generator for the burst itself; see ApplyFadeOut / ApplyFadeIn.
|
||||
private const int ConcealFadeFramesShort = 32;
|
||||
private const int ConcealFadeFramesLow = 96;
|
||||
// After this many consecutive empty-buffer reads, stop synthesising concealment and just
|
||||
// emit silence. Concealment is meant to mask brief transient gaps (a packet late by a few
|
||||
// ms); it should NOT fire forever when the sender has actually gone away. Without this
|
||||
// guard, killing the sender produced a "shshshsh" tremolo for ~4 s on the receiver — every
|
||||
// render callback wrote another noise burst into a buffer that never refilled, until the
|
||||
// AudioReceiver's idle-prune (4 s) tore the session down. 8 consecutive empties at typical
|
||||
// 5 ms ASIO render = 40 ms of repeated bursts before we give up; covers normal jitter
|
||||
// without bleeding into "sender gone" pauses.
|
||||
private const int ConcealmentMaxConsecutiveEmpties = 8;
|
||||
private bool inUnderrunConcealment;
|
||||
private int consecutiveEmptyReads;
|
||||
private float lastConcealSampleL;
|
||||
private float lastConcealSampleR;
|
||||
private volatile int concealmentArtifactRaw = (int)ConcealmentArtifact.NoiseBurst;
|
||||
// Per-session RNG for noise concealment. Seeded from process-level Shared so each session
|
||||
// gets a different sequence — but we don't care about reproducibility, just character.
|
||||
private readonly Random concealRng = new(Random.Shared.Next());
|
||||
|
||||
// === Drift correction (Phase 2, 2026-05-06) ===
|
||||
// Continuous low-rate clock-drift correction. The receiver and sender each have their own
|
||||
// audio crystal; over time their rates differ by a few-tens-of-ppm (typical for cheap USB
|
||||
// audio). Without correction, the playout buffer slowly drifts up (sender faster) or down
|
||||
// (sender slower) and eventually clicks via either overflow or underrun.
|
||||
//
|
||||
// The previous design corrected via a continuously-modulated WdlResampler — which produced
|
||||
// sample-level corruption and was the source of all the per-sample artefacts we hunted for
|
||||
// weeks (see analysis 2026-05-06). The replacement is the Jamulus / Mumble pattern:
|
||||
// **integrate the buffer-level error over time and discretely drop or repeat ONE STEREO
|
||||
// FRAME at a time when the integrator signals sustained drift.** A single-frame drop or
|
||||
// repeat at 48 kHz is 21 µs of audio — below the threshold of audibility on any normal
|
||||
// content, especially when timed by an integrator that fires only on sustained drift, not
|
||||
// on packet-arrival jitter.
|
||||
//
|
||||
// Mechanism per Read:
|
||||
// 1. Sample the current buffer level vs target.
|
||||
// 2. Integrate (buffer_level_error_frames * dt_sec * DriftGain) into driftAccumulator.
|
||||
// 3. If accumulator >= 1, drop one frame from the head of the playout buffer
|
||||
// (sender faster — we've consumed less than it produced; speed up consumption by
|
||||
// one frame). Decrement accumulator.
|
||||
// 4. If accumulator <= -1, queue a "repeat one frame" for the next Read (sender slower —
|
||||
// stall consumption by one frame). Increment accumulator.
|
||||
//
|
||||
// Behaviour by drift rate:
|
||||
// - 0 ppm (perfectly matched clocks): error stays near 0, accumulator stays near 0,
|
||||
// no corrections fire. Silent.
|
||||
// - 50 ppm drift (typical USB crystal mismatch = ~5 frames/sec on 48 kHz): accumulator
|
||||
// grows to ±1 every ~4 seconds; one frame correction every ~4 seconds. 21 µs of audio
|
||||
// dropped or repeated every ~4 seconds. Inaudible.
|
||||
// - Higher transient drift (e.g. system load briefly): integrator catches up within
|
||||
// seconds, brief burst of corrections, then settles. Still inaudible.
|
||||
//
|
||||
// The existing click-trim block above is kept as a safety net for catastrophic conditions
|
||||
// (large step changes that the slow integrator can't keep up with). At normal drift rates
|
||||
// the integrator never lets the buffer reach the click-trim threshold, so the trim should
|
||||
// effectively never fire in steady-state operation.
|
||||
private double driftAccumulatorFrames;
|
||||
private long prevDriftSampleTicks;
|
||||
private int pendingRepeatFrames;
|
||||
private long driftDropFramesTotal;
|
||||
private long driftRepeatFramesTotal;
|
||||
// Integrator gain. Lowered 2026-05-06 (10×) after an empirical test where the previous
|
||||
// gain (0.05) produced ~10 corrections per second on the user's hardware (two free-running
|
||||
// USB audio crystals with combined drift around 200 ppm = 10 frames/sec). Even with
|
||||
// single-frame corrections, 10 clicks/sec was audible. Lowering the gain alone trades
|
||||
// click rate for buffer drift; combined with the crossfade-on-splice change, each
|
||||
// correction is also significantly less audible per event.
|
||||
//
|
||||
// At 0.005, sustained 1-frame error reaches accumulator = 1 in ~200 seconds. For 200 ppm
|
||||
// drift (10 frames/sec error growth), the integrator catches up at ~2 corrections/sec
|
||||
// steady-state — which combined with crossfaded splices should push perceived click rate
|
||||
// toward inaudible.
|
||||
//
|
||||
// 2026-05-06 (later): added adaptive gain scaling. The base gain above is fine for steady-
|
||||
// state clock-drift compensation but pathologically slow when the buffer is far from
|
||||
// target — e.g. after a slider raise the buffer sits below target and drift correction
|
||||
// takes minutes to fill it. Empirically observed in user testing as "every session sounds
|
||||
// different": the buffer wandered for tens of seconds at whatever level the initial
|
||||
// arming chaos left it at. Now the effective gain scales linearly with absolute error
|
||||
// beyond the small-error band, capped, so:
|
||||
// * |error| <= DriftSmallErrorFrames: gain = DriftGain (today's behaviour, gentle)
|
||||
// * |error| > DriftSmallErrorFrames: gain = DriftGain × min(|error|/small, maxScale)
|
||||
// At 50 frames (~1 ms) the gain is 1×; at 1000 frames (~21 ms) it's 20× capped, giving
|
||||
// a fill rate of ~100 frames/sec — a 20 ms slider raise converges in ~10 seconds with
|
||||
// a barely-audible 0.2% rate offset during the fill.
|
||||
private const double DriftGain = 0.005;
|
||||
// Below this absolute error, gain stays at the steady-state baseline. ~1 ms at 48 kHz.
|
||||
private const double DriftSmallErrorFrames = 50;
|
||||
// Cap on adaptive-gain scale, so even huge errors don't produce an audible time-stretch
|
||||
// (200/sec frame edits = 0.42% rate change, edge of noticeable on tonal content).
|
||||
private const double DriftMaxGainScale = 20.0;
|
||||
// Number of stereo frames each side of a splice point that get blended when a drop or
|
||||
// repeat fires. Cosine crossfade over this window smooths the discontinuity into an audio
|
||||
// characteristic that's much harder to perceive as a click. 8 frames = 167 µs at 48 kHz —
|
||||
// shorter than a typical impulse response, so the smear doesn't blur transients audibly.
|
||||
private const int DriftCrossfadeFrames = 8;
|
||||
// Pending corrections (sample-aligned single-frame edits at the next Read).
|
||||
private int pendingDropFrames;
|
||||
// Public accessors for the diag log.
|
||||
public long DriftDropFramesTotal => Interlocked.Read(ref driftDropFramesTotal);
|
||||
public long DriftRepeatFramesTotal => Interlocked.Read(ref driftRepeatFramesTotal);
|
||||
|
||||
public IPEndPoint Endpoint { get; }
|
||||
/// <summary>The stream ID this session was opened for. Sessions are keyed by
|
||||
/// (Endpoint, StreamId) so a single peer can produce multiple simultaneous streams
|
||||
/// (e.g. WASAPI lane + ASIO lane in the native-independent mode). For single-lane
|
||||
/// modes there's still one session per peer with whatever streamId the sender chose
|
||||
/// (currently 1).</summary>
|
||||
public ushort StreamId { get; }
|
||||
/// <summary>Which render route this session's audio belongs to. Set by AudioReceiver
|
||||
/// from the format packet's Lane byte at session-creation (and updated on the rare
|
||||
/// in-place format change that keeps the same SessionPlayout alive). PlayoutEngine
|
||||
/// uses this to decide which of its per-route IWaveProvider surfaces this session
|
||||
/// contributes to. Defaults to <see cref="RenderRoute.Mixed"/> — the value an old
|
||||
/// sender or a classic-mode (WasapiOnly / AsioOnly / Both) sender writes.</summary>
|
||||
public RenderRoute Route { get; set; } = RenderRoute.Mixed;
|
||||
public int BufferedBytes => playout.BufferedBytes;
|
||||
public int BufferedMs => playout.BufferedBytes / MixBytesPerFrame * 1000 / MixSampleRate;
|
||||
public long UnderrunCount => playout.UnderrunCount;
|
||||
public long DropCount => playout.DropCount;
|
||||
public bool IsArmed => playbackArmed;
|
||||
|
||||
/// <summary>Per-cause drop accessors (cumulative bytes / counts since session start).
|
||||
/// Splits the previously-opaque DropCount so the diag log can distinguish click-trim
|
||||
/// from drain-on-knob-change from ringbuffer overflow. AggregateDrops on the engine
|
||||
/// continues to expose the rolled-up total for back-compat.</summary>
|
||||
public long TrimDropBytes => Interlocked.Read(ref trimDropBytes);
|
||||
public long DrainDropBytes => Interlocked.Read(ref drainDropBytes);
|
||||
public long TrimFireCount => Interlocked.Read(ref trimFireCount);
|
||||
|
||||
/// <summary>Sets the concealment artifact this session's playout uses on underrun gaps.
|
||||
/// Takes effect on the very next gap; no need to restart playback. Receiver-side only —
|
||||
/// the sender doesn't see this and wouldn't behave differently if it did.</summary>
|
||||
public void SetConcealmentArtifact(ConcealmentArtifact value) =>
|
||||
concealmentArtifactRaw = (int)value;
|
||||
|
||||
/// <summary>UTC time of the most recent successful audio write. Used by <see cref="AudioReceiver"/>
|
||||
/// to prune long-idle sessions so the dictionary doesn't grow unboundedly.</summary>
|
||||
public DateTime LastWriteUtc { get; private set; } = DateTime.UtcNow;
|
||||
|
||||
public SessionPlayout(IPEndPoint endpoint, ushort streamId, int capacityBytes)
|
||||
{
|
||||
Endpoint = endpoint;
|
||||
StreamId = streamId;
|
||||
playout = new AudioRingBuffer(capacityBytes);
|
||||
}
|
||||
|
||||
public void Write(ReadOnlySpan<byte> source)
|
||||
{
|
||||
var ms = source.Length * 1000 / MixBytesPerSecond;
|
||||
if (ms > largestWriteMs) largestWriteMs = ms;
|
||||
playout.Write(source);
|
||||
LastWriteUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Network-thread callback after a frame has been queued. Arms playback the moment this
|
||||
/// session's buffer first reaches the user's target; subsequent reads then engage the
|
||||
/// drift corrector. Each session arms independently, so a newly-arrived sender can start
|
||||
/// playing without waiting for already-armed sessions.
|
||||
///
|
||||
/// Also enforces a CATASTROPHIC-only cap on buffer level: if audio piles up beyond 1 second
|
||||
/// (because the render thread hasn't started yet, or got stuck), we trim down to 250 ms.
|
||||
/// The threshold is intentionally far above any reasonable jitter cushion — earlier we used
|
||||
/// 3× target which fought with the drift corrector on a noisy WAN (target 10 ms, real
|
||||
/// jitter up to 76 ms ⇒ buffer was being trimmed every second, causing the very clicking
|
||||
/// it was supposed to avoid). Now the cap is purely a safety net against catastrophic
|
||||
/// backlogs (multi-second pile-ups while no consumer exists); ordinary jitter is absorbed
|
||||
/// by the buffer + drift corrector + click-trim combo.
|
||||
/// </summary>
|
||||
public void NoteFramesQueued(int targetLatencyMs)
|
||||
{
|
||||
const int CatastrophicCapMs = 1000;
|
||||
const int CatastrophicTrimToMs = 250;
|
||||
if (playout.BufferedBytes > MillisecondsToBytes(CatastrophicCapMs))
|
||||
{
|
||||
playout.TrimFromProducer(MillisecondsToBytes(CatastrophicTrimToMs));
|
||||
}
|
||||
|
||||
if (playbackArmed) return;
|
||||
if (playout.BufferedBytes >= MillisecondsToBytes(Math.Max(targetLatencyMs, 1)))
|
||||
{
|
||||
playbackArmed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Disarm and request a drain on the next read — used when the user raises or
|
||||
/// lowers the latency knob. The mix bus continues with whatever's already armed.</summary>
|
||||
public void DisarmAndRequestDrain()
|
||||
{
|
||||
playbackArmed = false;
|
||||
drainRequested = true;
|
||||
}
|
||||
|
||||
/// <summary>Reset the buffer and per-session state. Used at start/stop. Arming will rebuild
|
||||
/// from the next packets that arrive.</summary>
|
||||
public void Reset()
|
||||
{
|
||||
playout.Reset();
|
||||
playbackArmed = false;
|
||||
largestWriteMs = 0;
|
||||
inUnderrunConcealment = false;
|
||||
consecutiveEmptyReads = 0;
|
||||
lastConcealSampleL = 0f;
|
||||
lastConcealSampleR = 0f;
|
||||
driftAccumulatorFrames = 0;
|
||||
prevDriftSampleTicks = 0;
|
||||
pendingDropFrames = 0;
|
||||
pendingRepeatFrames = 0;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// AudioRingBuffer is managed; nothing to free explicitly. Method present for symmetry
|
||||
// with Stream/Capture sessions and to allow future per-session unmanaged state.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WASAPI/ASIO render thread. Pulls <paramref name="outFrames"/> stereo frames from this
|
||||
/// session's playout ring into <paramref name="output"/>, applying drift correction and
|
||||
/// underrun concealment along the way. Returns the count of frames actually produced; if
|
||||
/// the session is disarmed (or drained completely) the return is 0 and
|
||||
/// <paramref name="output"/> is untouched (caller is responsible for zero-fill).
|
||||
/// </summary>
|
||||
public int ReadFloats(Span<float> output, int outFrames, int targetLatencyMs, int currentMaxLatencyMs, int smoothness = 3)
|
||||
{
|
||||
// Drain on user knob change.
|
||||
if (drainRequested)
|
||||
{
|
||||
drainRequested = false;
|
||||
var targetBytes = MillisecondsToBytes(targetLatencyMs);
|
||||
var buffered = playout.BufferedBytes;
|
||||
if (buffered > targetBytes)
|
||||
{
|
||||
var bytes = buffered - targetBytes;
|
||||
playout.DropOldest(bytes);
|
||||
Interlocked.Add(ref drainDropBytes, bytes);
|
||||
}
|
||||
}
|
||||
|
||||
if (!playbackArmed)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// NOTE: there used to be an "auto-disarm if buffer empty" block here. It was added
|
||||
// 2026-04-30 to clean up phantom underrun counts after a peer disconnect (the
|
||||
// 4-second idle-prune fires later, so without auto-disarm the underrun counter would
|
||||
// climb at ~100/sec while we waited). The comment claimed "mix output unchanged
|
||||
// either way" — but that was wrong at tight target latency.
|
||||
//
|
||||
// What auto-disarm did wrong: any time the buffer dipped to zero even momentarily
|
||||
// (ordinary sender-side mix-tick jitter — a 16ms gap between packets is normal on
|
||||
// Windows), it would disarm the session and return 0. ReadFloats then output silence
|
||||
// until packets refilled the buffer ALL THE WAY BACK to the user's target latency
|
||||
// and NoteFramesQueued re-armed. Each transient underrun became a 10-15ms silence
|
||||
// gap instead of a few-ms pad. That's the audible click that Ed kept hearing on
|
||||
// localhost at target=10 vs the older build that ran clean.
|
||||
//
|
||||
// Now: a momentary empty buffer just produces a small silence-pad on this Read (the
|
||||
// underrun counter still increments via playout.ReadFloats's own silence-fill — fine,
|
||||
// that's diagnostic noise not audio noise). The 4-second idle-prune in AudioReceiver
|
||||
// still handles long-term disconnect by tearing the session down entirely. No auto-
|
||||
// disarm needed.
|
||||
|
||||
// === Click-based buffer-smoothness trim ===
|
||||
//
|
||||
// The Buffer-smoothness knob (1 = aggressive, 10 = smooth) controls how aggressively
|
||||
// we DROP oldest samples when the buffer drifts above target. When (bufferedMs >
|
||||
// target + trimMargin) we drop the excess down to target. Causes a brief click at the
|
||||
// drop point but holds the queue right at the user's chosen latency.
|
||||
//
|
||||
// Largely a safety net post-Phase 2: the drift corrector below keeps the buffer near
|
||||
// target in normal operation, so the trim only fires under catastrophic conditions
|
||||
// (large step changes the slow integrator can't keep up with). Replaced an earlier
|
||||
// resampler-rate controller that pitch-shifted music while correcting drift — clicks
|
||||
// turned out to be the lesser evil, and the trim itself is a direct DropOldest on the
|
||||
// ring buffer (no resampler involved), so it's guaranteed to fire when needed.
|
||||
//
|
||||
// Margin and drop-destination computation. SPLIT BY KNOB:
|
||||
//
|
||||
// smoothness == 1 ("stupid aggressive" — used in ASIO Tight Latency mode for
|
||||
// sub-10 ms target):
|
||||
// floor = largestWriteMs * 2 + 4 (min 4)
|
||||
// drop-to = target + largestWriteMs (one packet's cushion only)
|
||||
// This is the original "reconnect-feel" tightness — tight threshold,
|
||||
// tight drop, frequent clicks but glued to target. Don't touch this. It's
|
||||
// what makes ASIO at target=1 ms snap back to target after every burst.
|
||||
//
|
||||
// smoothness >= 2:
|
||||
// floor = largestWriteMs * 4 + 4 (min 15)
|
||||
// drop-to = target + largestWriteMs * 2 + 5 (covers render-period +
|
||||
// frame + jitter pad)
|
||||
// The looser values prevent default-smoothness-3 from tripping on routine
|
||||
// startup bursts (96 kHz device + Opus 5 ms saw a 40 ms initial buffer that
|
||||
// exceeded the old default threshold of 32 ms — the rate controller would
|
||||
// have handled it in a few seconds, but trim fired and dropped the buffer
|
||||
// too low to absorb normal sender jitter, causing 20 underruns/sec for the
|
||||
// rest of the session).
|
||||
//
|
||||
// Direct evidence: localhost 96 kHz Opus test 2026-05-02 06:37 with
|
||||
// smoothness=3 — drops=9600 from one startup trim, then 20 underruns/sec.
|
||||
// Subsequent 48 kHz test with same smoothness ran clean because trim never
|
||||
// fired (buffer never quite reached the 32 ms threshold). The looser default
|
||||
// floor lets the rate controller handle initial transients on either device
|
||||
// rate.
|
||||
//
|
||||
// Examples at target=10 ms:
|
||||
// smoothness=1:
|
||||
// PCM Tight 2.5: floor=8. drop to 12.5. trims at >18.
|
||||
// Opus 10: floor=24. drop to 20. trims at >34.
|
||||
// smoothness=3 (default):
|
||||
// PCM Tight 2.5: floor=15. drop to 19. trims at >33.
|
||||
// PCM 5: floor=24. drop to 25. trims at >42.
|
||||
// Opus 10: floor=44. drop to 35. trims at >62.
|
||||
var clampedKnob = Math.Clamp(smoothness, 1, 10);
|
||||
var aggressive = clampedKnob == 1;
|
||||
var floorMarginMs = aggressive
|
||||
? Math.Max(largestWriteMs * 2 + 4, 4)
|
||||
: Math.Max(largestWriteMs * 4 + 4, 15);
|
||||
var dropToCushionMs = aggressive
|
||||
? largestWriteMs
|
||||
: largestWriteMs * 2 + 5;
|
||||
var knobExtraMs = clampedKnob switch
|
||||
{
|
||||
1 => 0,
|
||||
2 => 3,
|
||||
3 => 8,
|
||||
4 => 16,
|
||||
5 => 28,
|
||||
6 => 45,
|
||||
7 => 70,
|
||||
8 => 110,
|
||||
9 => 200,
|
||||
_ => -1, // 10 = no trim
|
||||
};
|
||||
if (knobExtraMs >= 0)
|
||||
{
|
||||
var trimMarginMs = floorMarginMs + knobExtraMs;
|
||||
var trimThresholdBytes = MillisecondsToBytes(targetLatencyMs + trimMarginMs);
|
||||
if (playout.BufferedBytes > trimThresholdBytes)
|
||||
{
|
||||
var keepBytes = MillisecondsToBytes(Math.Max(targetLatencyMs + dropToCushionMs, 1));
|
||||
var dropBytes = playout.BufferedBytes - keepBytes;
|
||||
if (dropBytes > 0)
|
||||
{
|
||||
playout.DropOldest(dropBytes);
|
||||
Interlocked.Add(ref trimDropBytes, dropBytes);
|
||||
Interlocked.Increment(ref trimFireCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Drift correction (Phase 2) ===
|
||||
//
|
||||
// Continuously integrate buffer-level error and drop / repeat single frames at low
|
||||
// rate to keep buffer aligned with target despite clock-drift between sender and
|
||||
// receiver crystals. Replaces the continuous adaptive resampling that produced
|
||||
// sample-level artefacts (analysed 2026-05-06). See the field-block comment above
|
||||
// for the design rationale.
|
||||
//
|
||||
// SAMPLE-RATE MISMATCH (future): the direct read below requires input PCM to already
|
||||
// be at MixSampleRate (48 kHz). When endpoints have mismatched device rates (e.g.
|
||||
// one machine at 44.1 kHz), the sender's MixingEngine still resamples to 48 kHz on
|
||||
// the capture side so the wire format is consistent — but if a future change emits
|
||||
// at the source's native rate, we'd need a FIXED-ratio resampler here (input_rate /
|
||||
// 48000, computed once, never modulated). The continuous-modulation pattern was the
|
||||
// bug; a fixed ratio is fine.
|
||||
var driftTicks = Stopwatch.GetTimestamp();
|
||||
var driftTargetBytes = MillisecondsToBytes(targetLatencyMs);
|
||||
if (prevDriftSampleTicks != 0)
|
||||
{
|
||||
var dtSec = (driftTicks - prevDriftSampleTicks) / (double)Stopwatch.Frequency;
|
||||
var errorFrames = ((double)playout.BufferedBytes - driftTargetBytes) / MixBytesPerFrame;
|
||||
// Adaptive gain: baseline at small errors (gentle steady-state compensation for
|
||||
// clock drift) but accelerated at large errors (fast convergence after a slider
|
||||
// raise or initial arming overshoot). Without this, the buffer can sit at any
|
||||
// level between 0 and target+jitter for tens of seconds — making sessions feel
|
||||
// randomly different. With this, the buffer reliably reaches target within a few
|
||||
// seconds of any disturbance.
|
||||
var absErrorFrames = errorFrames < 0 ? -errorFrames : errorFrames;
|
||||
var gainScale = absErrorFrames <= DriftSmallErrorFrames
|
||||
? 1.0
|
||||
: Math.Min(absErrorFrames / DriftSmallErrorFrames, DriftMaxGainScale);
|
||||
driftAccumulatorFrames += errorFrames * dtSec * DriftGain * gainScale;
|
||||
// Clamp to prevent runaway in pathological conditions (e.g. session pause).
|
||||
if (driftAccumulatorFrames > 100.0) driftAccumulatorFrames = 100.0;
|
||||
else if (driftAccumulatorFrames < -100.0) driftAccumulatorFrames = -100.0;
|
||||
}
|
||||
prevDriftSampleTicks = driftTicks;
|
||||
|
||||
// Queue at most one correction per Read so corrections spread evenly rather than burst.
|
||||
if (driftAccumulatorFrames >= 1.0)
|
||||
{
|
||||
pendingDropFrames++;
|
||||
driftAccumulatorFrames -= 1.0;
|
||||
}
|
||||
else if (driftAccumulatorFrames <= -1.0)
|
||||
{
|
||||
pendingRepeatFrames++;
|
||||
driftAccumulatorFrames += 1.0;
|
||||
}
|
||||
|
||||
// === Read with optional crossfaded drop / repeat ===
|
||||
//
|
||||
// The trick to audibly-clean drift correction: don't perform the splice as a hard
|
||||
// cut. Read one extra frame (drop) or one fewer frame (repeat) from the buffer, then
|
||||
// CROSSFADE around the splice point over DriftCrossfadeFrames samples. The cosine
|
||||
// window blends the audio either side of the splice into a smooth smear instead of
|
||||
// a discontinuity. At 8 frames (~167 µs at 48 kHz) the smear is much shorter than
|
||||
// any audible transient and far less perceptible than the original sample-level
|
||||
// discontinuity.
|
||||
//
|
||||
// Splice position: middle of the output buffer. Could choose a low-amplitude moment
|
||||
// for further inaudibility (PSOLA-style) but middle-of-buffer is good enough on
|
||||
// typical content and keeps the code simple.
|
||||
var dropThisCall = pendingDropFrames > 0 && outFrames > DriftCrossfadeFrames * 2 ? 1 : 0;
|
||||
var repeatThisCall = pendingRepeatFrames > 0 && outFrames > DriftCrossfadeFrames * 2 ? 1 : 0;
|
||||
// Don't try to do both in the same Read; they'd cancel anyway.
|
||||
if (dropThisCall > 0 && repeatThisCall > 0) { dropThisCall = 0; repeatThisCall = 0; }
|
||||
|
||||
if (dropThisCall > 0)
|
||||
{
|
||||
// Read outFrames + 1 frames into the output span by reading the first half,
|
||||
// skipping the splice with crossfade, then reading the second half. We need
|
||||
// a small extra-sample scratch for the splice. Reuse driftScratch as
|
||||
// temp storage (it's already managed and grows with outFrames).
|
||||
var extraFloats = (outFrames + 1) * MixChannels;
|
||||
if (driftScratch.Length < extraFloats)
|
||||
{
|
||||
driftScratch = new float[extraFloats];
|
||||
}
|
||||
var temp = driftScratch.AsSpan(0, extraFloats);
|
||||
ReadInputWithConcealment(temp);
|
||||
// Crossfade the splice. Splice position = midpoint of the output frame.
|
||||
// Result: outFrames samples where one is "elided" via a cosine cross-blend.
|
||||
ApplyDropCrossfade(temp, output, outFrames);
|
||||
pendingDropFrames--;
|
||||
Interlocked.Increment(ref driftDropFramesTotal);
|
||||
}
|
||||
else if (repeatThisCall > 0)
|
||||
{
|
||||
// Read outFrames - 1 frames into temp, then expand to outFrames via a crossfaded
|
||||
// insertion at the splice point.
|
||||
var shortFloats = (outFrames - 1) * MixChannels;
|
||||
if (driftScratch.Length < shortFloats)
|
||||
{
|
||||
driftScratch = new float[shortFloats];
|
||||
}
|
||||
var temp = driftScratch.AsSpan(0, shortFloats);
|
||||
ReadInputWithConcealment(temp);
|
||||
ApplyRepeatCrossfade(temp, output, outFrames);
|
||||
pendingRepeatFrames--;
|
||||
Interlocked.Increment(ref driftRepeatFramesTotal);
|
||||
}
|
||||
else
|
||||
{
|
||||
ReadInputWithConcealment(output);
|
||||
}
|
||||
return outFrames;
|
||||
}
|
||||
|
||||
/// <summary>Drop-mode crossfade: temp has (outFrames + 1) frames, output gets outFrames
|
||||
/// frames with one elided at the splice via a cosine blend across DriftCrossfadeFrames
|
||||
/// samples on each side.</summary>
|
||||
private static void ApplyDropCrossfade(ReadOnlySpan<float> temp, Span<float> output, int outFrames)
|
||||
{
|
||||
// Splice at midpoint of output frames. The "skipped" sample in temp lives at index
|
||||
// spliceIdx; either side of it gets cross-blended.
|
||||
var spliceIdx = outFrames / 2;
|
||||
var window = DriftCrossfadeFrames;
|
||||
var halfWindow = window / 2;
|
||||
|
||||
// Pre-window: copy temp[0..spliceIdx-halfWindow] verbatim.
|
||||
var preEnd = spliceIdx - halfWindow;
|
||||
if (preEnd > 0)
|
||||
{
|
||||
temp.Slice(0, preEnd * MixChannels).CopyTo(output);
|
||||
}
|
||||
|
||||
// Window: cosine crossfade. As we walk through `window` output frames, blend from
|
||||
// temp[preEnd + k] (the "before-skip" sample) toward temp[preEnd + 1 + k] (the
|
||||
// "after-skip" sample). The blend mixes consecutive temp positions so the splice
|
||||
// is spread out smoothly.
|
||||
for (var k = 0; k < window; k++)
|
||||
{
|
||||
var t = (k + 1) / (double)(window + 1);
|
||||
// Cosine-shaped smooth fade from 0 to 1 across the window.
|
||||
var fadeIn = (float)((1.0 - Math.Cos(Math.PI * t)) * 0.5);
|
||||
var fadeOut = 1f - fadeIn;
|
||||
var beforeIdx = (preEnd + k) * MixChannels;
|
||||
var afterIdx = (preEnd + 1 + k) * MixChannels;
|
||||
var dstIdx = (preEnd + k) * MixChannels;
|
||||
output[dstIdx] = temp[beforeIdx] * fadeOut + temp[afterIdx] * fadeIn;
|
||||
output[dstIdx + 1] = temp[beforeIdx + 1] * fadeOut + temp[afterIdx + 1] * fadeIn;
|
||||
}
|
||||
|
||||
// Post-window: copy temp[spliceIdx+halfWindow+1..outFrames+1] to output[spliceIdx+halfWindow..outFrames].
|
||||
// The "+1" on the source side is the elision: we skip one frame from temp.
|
||||
var postStartTemp = spliceIdx + halfWindow + 1;
|
||||
var postStartOut = spliceIdx + halfWindow;
|
||||
var postLen = outFrames - postStartOut;
|
||||
if (postLen > 0)
|
||||
{
|
||||
temp.Slice(postStartTemp * MixChannels, postLen * MixChannels)
|
||||
.CopyTo(output.Slice(postStartOut * MixChannels));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Repeat-mode crossfade: temp has (outFrames - 1) frames, output gets outFrames
|
||||
/// with one synthesised at the splice via a cosine blend that "stretches" temp by one
|
||||
/// frame.</summary>
|
||||
private static void ApplyRepeatCrossfade(ReadOnlySpan<float> temp, Span<float> output, int outFrames)
|
||||
{
|
||||
var spliceIdx = outFrames / 2;
|
||||
var window = DriftCrossfadeFrames;
|
||||
var halfWindow = window / 2;
|
||||
|
||||
// Pre-window: copy temp[0..spliceIdx-halfWindow] verbatim.
|
||||
var preEnd = spliceIdx - halfWindow;
|
||||
if (preEnd > 0)
|
||||
{
|
||||
temp.Slice(0, preEnd * MixChannels).CopyTo(output);
|
||||
}
|
||||
|
||||
// Window of (window + 1) output frames mapped to (window) temp frames. Cosine
|
||||
// crossfade synthesizes the extra frame: each output sample in the window is a
|
||||
// blend of two adjacent temp samples, with the blend weight progressing slower than
|
||||
// the index, effectively inserting a "smoothed" extra sample.
|
||||
for (var k = 0; k <= window; k++)
|
||||
{
|
||||
var t = k / (double)(window + 1);
|
||||
var fadeIn = (float)((1.0 - Math.Cos(Math.PI * t)) * 0.5);
|
||||
var fadeOut = 1f - fadeIn;
|
||||
// Map output index -> temp position: output[preEnd+k] takes from temp[preEnd+k-1] and temp[preEnd+k].
|
||||
// For k=0 we use temp[preEnd] alone; for k=window we use temp[preEnd+window-1] alone.
|
||||
var leftTempIdx = Math.Max(0, preEnd + k - 1) * MixChannels;
|
||||
var rightTempIdx = Math.Min(temp.Length / MixChannels - 1, preEnd + k) * MixChannels;
|
||||
var dstIdx = (preEnd + k) * MixChannels;
|
||||
output[dstIdx] = temp[leftTempIdx] * fadeOut + temp[rightTempIdx] * fadeIn;
|
||||
output[dstIdx + 1] = temp[leftTempIdx + 1] * fadeOut + temp[rightTempIdx + 1] * fadeIn;
|
||||
}
|
||||
|
||||
// Post-window: copy temp[spliceIdx+halfWindow..outFrames-1] to output[spliceIdx+halfWindow+1..outFrames].
|
||||
var postStartTemp = spliceIdx + halfWindow;
|
||||
var postStartOut = spliceIdx + halfWindow + 1;
|
||||
var postLen = outFrames - postStartOut;
|
||||
if (postLen > 0)
|
||||
{
|
||||
temp.Slice(postStartTemp * MixChannels, postLen * MixChannels)
|
||||
.CopyTo(output.Slice(postStartOut * MixChannels));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps <see cref="AudioRingBuffer.ReadFloats"/> with packet-loss-style concealment.
|
||||
/// On a short read, replaces the silence-filled tail with a brief synthesised burst
|
||||
/// (character chosen by <see cref="SetConcealmentArtifact"/>) decaying to zero. On the
|
||||
/// next full read after a gap, applies a matching fade-in so the resumed audio doesn't
|
||||
/// start with a hard discontinuity. The result is a smooth attack-and-release at the
|
||||
/// edges of any gap — the human ear is much more forgiving of "dipped briefly then came
|
||||
/// back" than of "instant click into silence and instant click back".
|
||||
///
|
||||
/// Stereo-only (matches the rest of the audio path). Output flows through the mix bus
|
||||
/// and limiter as usual.
|
||||
/// </summary>
|
||||
private void ReadInputWithConcealment(Span<float> inSpan)
|
||||
{
|
||||
var requestedFloats = inSpan.Length;
|
||||
var floatsRead = playout.ReadFloats(inSpan);
|
||||
var requestedFrames = requestedFloats / MixChannels;
|
||||
var framesRead = floatsRead / MixChannels;
|
||||
|
||||
var artifact = (ConcealmentArtifact)concealmentArtifactRaw;
|
||||
|
||||
if (framesRead < requestedFrames)
|
||||
{
|
||||
// Don't synthesise concealment forever during a sustained empty-buffer state — the
|
||||
// sender has probably gone away. After N consecutive empty reads we just leave the
|
||||
// buffer's hard-zero in place; result is true silence rather than a "shshshsh"
|
||||
// tremolo as the noise/cosine artifact retriggers each render callback.
|
||||
consecutiveEmptyReads = framesRead == 0 ? consecutiveEmptyReads + 1 : 0;
|
||||
if (consecutiveEmptyReads <= ConcealmentMaxConsecutiveEmpties)
|
||||
{
|
||||
// AudioRingBuffer silence-filled inSpan[floatsRead..] with zero. Replace the
|
||||
// head of that silence with the chosen artifact, then leave the rest at zero.
|
||||
var silenceFrameStart = framesRead;
|
||||
var silenceFrameCount = requestedFrames - framesRead;
|
||||
ApplyFadeOut(inSpan, silenceFrameStart, silenceFrameCount, artifact);
|
||||
}
|
||||
inUnderrunConcealment = true;
|
||||
}
|
||||
else if (inUnderrunConcealment)
|
||||
{
|
||||
// First full read after a gap. Fade the new audio in from zero so we don't
|
||||
// instantly jump back to whatever the new audio's amplitude is.
|
||||
ApplyFadeIn(inSpan, requestedFrames, artifact);
|
||||
inUnderrunConcealment = false;
|
||||
consecutiveEmptyReads = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
consecutiveEmptyReads = 0;
|
||||
}
|
||||
|
||||
// Remember the last real sample for the next fade-out. Use the last frame of actual
|
||||
// ring data, not anything we just synthesised. (Only meaningful if we read at least
|
||||
// one real frame this call — i.e. framesRead > 0.)
|
||||
if (framesRead > 0)
|
||||
{
|
||||
var lastIdx = (framesRead - 1) * MixChannels;
|
||||
lastConcealSampleL = inSpan[lastIdx];
|
||||
lastConcealSampleR = inSpan[lastIdx + 1];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Synthesises the fade-out burst for the chosen artifact into the silence
|
||||
/// region starting at <paramref name="startFrame"/>. Click variant leaves the buffer's
|
||||
/// hard-zero in place.</summary>
|
||||
private void ApplyFadeOut(Span<float> inSpan, int startFrame, int silenceFrameCount, ConcealmentArtifact artifact)
|
||||
{
|
||||
if (artifact == ConcealmentArtifact.Click) return; // Hard zero; produces the original click.
|
||||
|
||||
var fadeLen = artifact == ConcealmentArtifact.CosineToneLow
|
||||
? ConcealFadeFramesLow
|
||||
: ConcealFadeFramesShort;
|
||||
var fadeFrames = Math.Min(fadeLen, silenceFrameCount);
|
||||
for (var f = 0; f < fadeFrames; f++)
|
||||
{
|
||||
// Common envelope: cosine ramp from 1.0 → 0.0 across the fade region.
|
||||
var t = (f + 1) / (double)fadeFrames;
|
||||
var g = (float)((Math.Cos(Math.PI * t) + 1.0) * 0.5);
|
||||
var idx = (startFrame + f) * MixChannels;
|
||||
switch (artifact)
|
||||
{
|
||||
case ConcealmentArtifact.NoiseBurst:
|
||||
// White noise at last-sample peak amplitude. Random per channel — broader
|
||||
// stereo image than mono noise, and avoids correlated content the brain
|
||||
// can latch onto as a tone.
|
||||
var peak = Math.Max(Math.Abs(lastConcealSampleL), Math.Abs(lastConcealSampleR));
|
||||
inSpan[idx] = ((float)concealRng.NextDouble() * 2f - 1f) * peak * g;
|
||||
inSpan[idx + 1] = ((float)concealRng.NextDouble() * 2f - 1f) * peak * g;
|
||||
break;
|
||||
default:
|
||||
// Cosine-tone variants (short/low). Hold last sample, scaled by envelope.
|
||||
inSpan[idx] = lastConcealSampleL * g;
|
||||
inSpan[idx + 1] = lastConcealSampleR * g;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fades the resumed audio in from zero with the same cosine envelope used on
|
||||
/// the way out. Click variant skips the fade — the goal of "Click" is to expose the
|
||||
/// original raw zero-fill behaviour, including its discontinuity at audio resumption.</summary>
|
||||
private static void ApplyFadeIn(Span<float> inSpan, int requestedFrames, ConcealmentArtifact artifact)
|
||||
{
|
||||
if (artifact == ConcealmentArtifact.Click) return;
|
||||
|
||||
var fadeLen = artifact == ConcealmentArtifact.CosineToneLow
|
||||
? ConcealFadeFramesLow
|
||||
: ConcealFadeFramesShort;
|
||||
var fadeFrames = Math.Min(fadeLen, requestedFrames);
|
||||
for (var f = 0; f < fadeFrames; f++)
|
||||
{
|
||||
var t = f / (double)fadeFrames;
|
||||
var g = (float)((1.0 - Math.Cos(Math.PI * t)) * 0.5);
|
||||
var idx = f * MixChannels;
|
||||
inSpan[idx] *= g;
|
||||
inSpan[idx + 1] *= g;
|
||||
}
|
||||
}
|
||||
|
||||
private static int MillisecondsToBytes(int milliseconds) =>
|
||||
Math.Max(MixBytesPerFrame, milliseconds * MixBytesPerSecond / 1000);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using System.Net;
|
||||
using System.Runtime.InteropServices;
|
||||
using Concentus;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the per-sender decode pipeline. One sender = one StreamSession at a time. When a new
|
||||
/// sender appears (different remote endpoint, or stream/codec change), the receiver swaps in a
|
||||
/// new session — old buffered audio drains out of the playout buffer naturally during the
|
||||
/// swap rather than being thrown away mid-playback.
|
||||
///
|
||||
/// All work runs on the network listener's thread. No locks; the only cross-thread interaction
|
||||
/// is writing decoded float frames to the SPSC <see cref="AudioRingBuffer"/>.
|
||||
/// </summary>
|
||||
internal sealed class StreamSession : IDisposable
|
||||
{
|
||||
private readonly SessionPlayout sessionPlayout;
|
||||
private readonly ReceiverDiagnostics diagnostics;
|
||||
private readonly Action<int> onFramesQueued;
|
||||
private readonly PcmFrameAssembler pcmAssembler = new();
|
||||
private IOpusDecoder? opusDecoder;
|
||||
// Sequence-tracking for Opus FEC recovery. uint, so wrap-around is naturally
|
||||
// handled by the (current - expected == 1U) comparison at gap detection.
|
||||
private uint? expectedNextSequence;
|
||||
/// <summary>Number of single-packet gaps recovered using inband FEC from the next packet.</summary>
|
||||
public long OpusFecRecoveries { get; private set; }
|
||||
/// <summary>Number of multi-packet gaps where FEC could not help (only logs once per occurrence).</summary>
|
||||
public long OpusUnrecoveredGaps { get; private set; }
|
||||
|
||||
public IPEndPoint Endpoint { get; }
|
||||
public ushort StreamId { get; }
|
||||
public AudioFormatInfo Format { get; }
|
||||
public AudioTransportCodec Codec => (AudioTransportCodec)Format.Codec;
|
||||
|
||||
/// <summary>For PCM streams: number of incoming packets the assembler rejected outright.</summary>
|
||||
public long PcmFrameRejections => pcmAssembler.RejectionCount;
|
||||
/// <summary>For PCM streams: number of partially-assembled frames discarded mid-flight.</summary>
|
||||
public long PcmFrameDiscardedPartials => pcmAssembler.DiscardedPartialCount;
|
||||
|
||||
public StreamSession(
|
||||
IPEndPoint endpoint,
|
||||
ushort streamId,
|
||||
AudioFormatInfo format,
|
||||
SessionPlayout sessionPlayout,
|
||||
ReceiverDiagnostics diagnostics,
|
||||
Action<int> onFramesQueued)
|
||||
{
|
||||
Endpoint = endpoint;
|
||||
StreamId = streamId;
|
||||
Format = format;
|
||||
this.sessionPlayout = sessionPlayout;
|
||||
this.diagnostics = diagnostics;
|
||||
this.onFramesQueued = onFramesQueued;
|
||||
|
||||
if (Codec == AudioTransportCodec.Opus)
|
||||
{
|
||||
opusDecoder = OpusCodecFactory.CreateDecoder(format.SampleRate, format.Channels, TextWriter.Null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns true if this session matches the given format identity (codec/rate/channels/frame).</summary>
|
||||
public bool MatchesFormat(IPEndPoint endpoint, ushort streamId, AudioFormatInfo format) =>
|
||||
Endpoint.Equals(endpoint)
|
||||
&& StreamId == streamId
|
||||
&& Format.Codec == format.Codec
|
||||
&& Format.SampleRate == format.SampleRate
|
||||
&& Format.Channels == format.Channels
|
||||
&& Format.FrameDurationMilliseconds == format.FrameDurationMilliseconds;
|
||||
|
||||
public bool IsSameEndpoint(IPEndPoint endpoint) => Endpoint.Equals(endpoint);
|
||||
|
||||
public bool HandleAudioPayload(uint sequence, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
diagnostics.RecordPacketArrived();
|
||||
return Codec switch
|
||||
{
|
||||
AudioTransportCodec.Pcm => HandlePcm(payload),
|
||||
AudioTransportCodec.Opus => HandleOpus(sequence, payload),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose() { /* IOpusDecoder has no Dispose; nothing else to free */ }
|
||||
|
||||
// === PCM ===
|
||||
|
||||
private bool HandlePcm(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (!RemPcmFrame.TryReadSubHeader(payload, out var frameId, out var partIndex, out var totalParts))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var partBytes = payload[RemPcmFrame.SubHeaderSize..];
|
||||
if (!pcmAssembler.TryAssemble(partBytes, frameId, partIndex, totalParts, out var assembled))
|
||||
{
|
||||
return true; // pending or dropped due to mismatch — not an error condition
|
||||
}
|
||||
|
||||
// assembled is signed int24 LE, stereo. Convert to float32 and queue.
|
||||
var sampleCount = assembled.Length / 3;
|
||||
var floatBytes = sampleCount * sizeof(float);
|
||||
Span<byte> floatScratch = floatBytes <= 16 * 1024 ? stackalloc byte[floatBytes] : new byte[floatBytes];
|
||||
var floatSpan = MemoryMarshal.Cast<byte, float>(floatScratch);
|
||||
PcmPack.Int24LEToFloat(assembled, floatSpan);
|
||||
|
||||
sessionPlayout.Write(floatScratch);
|
||||
onFramesQueued(sampleCount / Format.Channels);
|
||||
return true;
|
||||
}
|
||||
|
||||
// === Opus ===
|
||||
|
||||
private bool HandleOpus(uint sequence, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (opusDecoder is null) return false;
|
||||
|
||||
var frameSize = Math.Max(1, Format.SampleRate * Math.Max(5, Format.FrameDurationMilliseconds) / 1000);
|
||||
var totalShorts = frameSize * Format.Channels;
|
||||
Span<short> shortScratch = totalShorts <= 4096 ? stackalloc short[totalShorts] : new short[totalShorts];
|
||||
|
||||
// Detect a single-packet gap. If the previous packet was N and this is N+2,
|
||||
// we know N+1 was lost; this packet's payload contains FEC redundancy for
|
||||
// it. Decode the FEC frame first (so audio plays in order), then the
|
||||
// current frame. Wrap-around with uint subtraction is intentional.
|
||||
bool useFecRecovery = false;
|
||||
if (expectedNextSequence is uint expected)
|
||||
{
|
||||
uint gap = sequence - expected; // 0 = exactly expected, 1 = one missing, 2+ = multi-loss
|
||||
if (gap == 1)
|
||||
{
|
||||
useFecRecovery = true;
|
||||
}
|
||||
else if (gap > 1 && gap < 1_000_000)
|
||||
{
|
||||
// Multi-packet loss — FEC can only recover one. Don't try.
|
||||
OpusUnrecoveredGaps++;
|
||||
}
|
||||
// gap == 0 OR a wild jump (gap >= 1M, e.g. stream reset) → no recovery
|
||||
}
|
||||
|
||||
if (useFecRecovery)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fecDecoded = opusDecoder.Decode(payload, shortScratch, frameSize, true);
|
||||
if (fecDecoded > 0)
|
||||
{
|
||||
EmitDecoded(shortScratch, fecDecoded);
|
||||
OpusFecRecoveries++;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// FEC recovery is best-effort; if it fails, fall through to the
|
||||
// normal decode and accept a single click rather than crashing.
|
||||
}
|
||||
}
|
||||
|
||||
int decoded;
|
||||
try
|
||||
{
|
||||
decoded = opusDecoder.Decode(payload, shortScratch, frameSize, false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (decoded <= 0) return false;
|
||||
|
||||
EmitDecoded(shortScratch, decoded);
|
||||
expectedNextSequence = sequence + 1U;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void EmitDecoded(ReadOnlySpan<short> shortScratch, int sampleCountPerChannel)
|
||||
{
|
||||
var floatCount = sampleCountPerChannel * Format.Channels;
|
||||
var floatBytes = floatCount * sizeof(float);
|
||||
Span<byte> floatScratch = floatBytes <= 16 * 1024 ? stackalloc byte[floatBytes] : new byte[floatBytes];
|
||||
var floatSpan = MemoryMarshal.Cast<byte, float>(floatScratch);
|
||||
for (var i = 0; i < floatCount; i++) floatSpan[i] = shortScratch[i] / 32768f;
|
||||
|
||||
sessionPlayout.Write(floatScratch);
|
||||
onFramesQueued(sampleCountPerChannel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
using System.Diagnostics;
|
||||
using NAudio.Wave;
|
||||
using NAudio.Wave.Asio;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// ASIO capture backend. Drives a single <see cref="AsioOut"/> for the chosen ASIO driver and
|
||||
/// produces 48 kHz stereo float frames in the same shape <see cref="MixingEngine"/> does, so
|
||||
/// <see cref="AudioSender"/> doesn't care which backend is active.
|
||||
///
|
||||
/// Spec identity: each <see cref="CaptureSourceSpec"/> for ASIO uses a synthetic
|
||||
/// <c>DeviceId</c> of the form <c>"asio:<channel-pair-index>"</c>. Channel pair 0 = ASIO
|
||||
/// channels 0+1, pair 1 = channels 2+3, etc. The driver is implicit (a single driver per
|
||||
/// session, configured through the Connectivity & transport dialog).
|
||||
///
|
||||
/// Limitations vs the WASAPI backend (deliberate to keep this manageable):
|
||||
/// • Driver is locked at <see cref="Start"/> time. Switching drivers means Stop + new instance.
|
||||
/// • We always open the AsioOut with the driver's full input channel count, regardless of
|
||||
/// which pairs the user selected. The unused channels are pulled but discarded. This
|
||||
/// trades a tiny amount of buffer memory for a big stability win: adding or removing a
|
||||
/// channel pair never requires reopening the driver, which means we don't fight a
|
||||
/// concurrent receiver-side AsioOut on single-client drivers (Komplete Audio etc.).
|
||||
/// • Sample rate is fixed at 48 kHz; if the driver doesn't support that, capture fails to
|
||||
/// start (the diagnostic line says so). All modern pro audio interfaces support 48 kHz.
|
||||
/// • Hardware loopback channels (e.g. EVO 8's Loop-back 1/2) are just regular ASIO inputs
|
||||
/// from our perspective; they live in the same channel space and are picked the same way.
|
||||
/// </summary>
|
||||
internal sealed class AsioCaptureBackend : ICaptureBackend
|
||||
{
|
||||
private const int MixSampleRate = 48000;
|
||||
private const int MixChannels = 2;
|
||||
|
||||
// Volatile-published callback. The ASIO audio thread reads this every callback to
|
||||
// decide where to deliver samples; AudioSender swaps it on mode changes so the same
|
||||
// open driver can keep running while routing changes between Mixed / AsioLane / no-op.
|
||||
// Volatile is sufficient for reference assignment on .NET (atomic, with memory barrier).
|
||||
private volatile Action<ReadOnlyMemory<float>> onMixedSamples;
|
||||
private readonly Action<string>? onDiagnostic;
|
||||
private readonly string driverName;
|
||||
public string DriverName => driverName;
|
||||
private readonly object gate = new();
|
||||
|
||||
private AsioOut? asio;
|
||||
private List<int> activeChannelPairIndices = [];
|
||||
private int recordChannelCount;
|
||||
private float[] mixScratch = new float[1024];
|
||||
private float[] interleavedScratch = new float[1024];
|
||||
|
||||
private long callbackCount;
|
||||
private long bytesCaptured;
|
||||
private long clippedSampleCount;
|
||||
private string? lastError;
|
||||
private string? captureFormat;
|
||||
private readonly Stopwatch uptime = new();
|
||||
// Per-callback gap tracking. The ASIO callback should fire on a strict period (= buffer
|
||||
// size in samples / sample rate). When the .NET runtime, GC, USB driver, or Windows
|
||||
// scheduler stalls the audio thread, that period stretches and the audio stream gets a
|
||||
// discontinuity — which the receiver can't detect because it just sees a packet arrive
|
||||
// late. We measure the elapsed time between consecutive callbacks here, track the worst
|
||||
// since the last read, and let the sender's diag logger surface it. Plain int; access
|
||||
// is via Interlocked which provides its own memory barriers (no need for volatile).
|
||||
private long lastCallbackTimestamp;
|
||||
private int maxCallbackGapMs;
|
||||
|
||||
public AsioCaptureBackend(string driverName, Action<ReadOnlyMemory<float>> onMixedSamples, Action<string>? onDiagnostic = null)
|
||||
{
|
||||
this.driverName = driverName;
|
||||
this.onMixedSamples = onMixedSamples;
|
||||
this.onDiagnostic = onDiagnostic;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Swap the callback that captured audio is delivered to. Used by AudioSender to keep
|
||||
/// one persistent AsioCaptureBackend instance alive across audio-mode changes — the
|
||||
/// driver stays open, the callback gets rewired to the lane appropriate for the new
|
||||
/// mode (Mixed in AsioOnly, AsioLane in BothIndependent, or a no-op while the
|
||||
/// composite is being rebuilt). Volatile write, so the audio thread picks the new
|
||||
/// callback up on its very next ASIO buffer.
|
||||
/// </summary>
|
||||
public void SetCallback(Action<ReadOnlyMemory<float>> callback) =>
|
||||
onMixedSamples = callback;
|
||||
|
||||
public bool IsRunning => asio is not null;
|
||||
public long TotalCaptureCallbacks => Interlocked.Read(ref callbackCount);
|
||||
public long TotalCaptureBytes => Interlocked.Read(ref bytesCaptured);
|
||||
public string? FirstCaptureFormatDescription => captureFormat;
|
||||
public string? FirstCaptureLastError => lastError;
|
||||
public long ClippedSampleCount => Interlocked.Read(ref clippedSampleCount);
|
||||
|
||||
public IReadOnlyList<string> ActiveSourceNames
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return activeChannelPairIndices
|
||||
.Select(p => $"{driverName} ASIO {p * 2 + 1}/{p * 2 + 2}")
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Start(IReadOnlyList<CaptureSourceSpec> specs)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (IsRunning) StopInternal();
|
||||
if (specs.Count == 0) return;
|
||||
|
||||
activeChannelPairIndices = ParseChannelPairIndices(specs);
|
||||
if (activeChannelPairIndices.Count == 0)
|
||||
{
|
||||
onDiagnostic?.Invoke("asio capture: no valid channel pair indices in spec list");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
asio = new AsioOut(driverName);
|
||||
// Always open with the driver's full input channel count. Pulling channels we
|
||||
// don't immediately need is essentially free — the driver fills them anyway —
|
||||
// and it removes the need to ever reopen the AsioOut when the user toggles a
|
||||
// higher-numbered channel pair. Reopening is what previously caused 15-second
|
||||
// freezes when both sender and receiver held the same single-client driver
|
||||
// (Komplete Audio etc.) — see Andre's localhost lockup, 2026-04-30.
|
||||
recordChannelCount = asio.DriverInputChannelCount;
|
||||
if (recordChannelCount <= 0)
|
||||
{
|
||||
onDiagnostic?.Invoke($"asio capture: driver \"{driverName}\" reports zero input channels");
|
||||
StopInternal();
|
||||
return;
|
||||
}
|
||||
asio.InputChannelOffset = 0;
|
||||
// Sanity-check that the requested pairs are within the driver's channel range.
|
||||
// We open the full count anyway, but if a saved spec references a pair above
|
||||
// the driver's range, the OnAudioAvailable mixer would silently emit zero —
|
||||
// surface that as a diagnostic so it's not mysterious.
|
||||
var maxPairIndex = activeChannelPairIndices.Max();
|
||||
var highestNeededChannel = (maxPairIndex + 1) * 2;
|
||||
if (highestNeededChannel > recordChannelCount)
|
||||
{
|
||||
onDiagnostic?.Invoke($"asio capture: driver \"{driverName}\" only has {recordChannelCount} input channels, but spec requests channel pair {maxPairIndex} (channels {maxPairIndex * 2 + 1}/{maxPairIndex * 2 + 2})");
|
||||
// Continue anyway — out-of-range pairs just contribute silence to the mix.
|
||||
}
|
||||
asio.InitRecordAndPlayback(null, recordChannelCount, MixSampleRate);
|
||||
asio.AudioAvailable += OnAudioAvailable;
|
||||
captureFormat = $"{MixSampleRate} Hz, {recordChannelCount} input channel(s), 32-bit float (ASIO)";
|
||||
asio.Play();
|
||||
uptime.Restart();
|
||||
onDiagnostic?.Invoke($"asio capture started \"{driverName}\" {captureFormat}; pairs={string.Join(",", activeChannelPairIndices)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex.Message;
|
||||
onDiagnostic?.Invoke($"asio capture start failed: {ex.GetType().Name}: {ex.Message}");
|
||||
StopInternal();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateSources(IReadOnlyList<CaptureSourceSpec> specs)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (!IsRunning)
|
||||
{
|
||||
Start(specs);
|
||||
return;
|
||||
}
|
||||
var newPairs = ParseChannelPairIndices(specs);
|
||||
// No reopen needed regardless of which pairs change. We always opened the driver
|
||||
// with its full input channel count at Start, so adding or removing a pair is just
|
||||
// a matter of which input channels the OnAudioAvailable mixer reads from. Even
|
||||
// when the new pair set is empty we DO NOT close the driver here — Audient's
|
||||
// ASIO driver (and several others) doesn't tolerate a close+reopen within a few
|
||||
// seconds, which is exactly the pattern the user produces by unticking the last
|
||||
// ASIO source and then ticking another one. Keeping the driver open with zero
|
||||
// active pairs makes the callback fire harmlessly (zeros) and the next pair
|
||||
// addition takes effect on the very next callback. The driver only truly closes
|
||||
// on Stop() or Dispose(), which fire on sender disabled or app exit.
|
||||
activeChannelPairIndices = newPairs;
|
||||
onDiagnostic?.Invoke($"asio capture: pairs updated to [{string.Join(",", activeChannelPairIndices)}] (no driver restart)");
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
lock (gate) StopInternal();
|
||||
}
|
||||
|
||||
private void StopInternal()
|
||||
{
|
||||
if (asio is not null)
|
||||
{
|
||||
try { asio.AudioAvailable -= OnAudioAvailable; } catch { /* ignore */ }
|
||||
try { asio.Stop(); } catch { /* ignore */ }
|
||||
try { asio.Dispose(); } catch { /* ignore */ }
|
||||
asio = null;
|
||||
}
|
||||
uptime.Stop();
|
||||
activeChannelPairIndices = [];
|
||||
recordChannelCount = 0;
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
private static List<int> ParseChannelPairIndices(IReadOnlyList<CaptureSourceSpec> specs)
|
||||
{
|
||||
var result = new List<int>();
|
||||
foreach (var spec in specs)
|
||||
{
|
||||
if (AsioDeviceId.TryParse(spec.DeviceId, out var pair))
|
||||
{
|
||||
result.Add(pair);
|
||||
}
|
||||
}
|
||||
result.Sort();
|
||||
return result.Distinct().ToList();
|
||||
}
|
||||
|
||||
public int TakeMaxCallbackGapMs() => Interlocked.Exchange(ref maxCallbackGapMs, 0);
|
||||
|
||||
private void OnAudioAvailable(object? sender, AsioAudioAvailableEventArgs e)
|
||||
{
|
||||
Interlocked.Increment(ref callbackCount);
|
||||
// Capture-callback gap timing. First callback seeds the timestamp without recording a
|
||||
// gap (we have nothing to compare to). Subsequent callbacks compute the elapsed ms
|
||||
// since the previous one and CAS-update the max. Skipped entirely when diagnostics
|
||||
// are off — saves the Stopwatch reads, exchange and CAS loop on every ASIO callback.
|
||||
if (RemSound.Core.DiagnosticsGate.Enabled)
|
||||
{
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
var prev = Interlocked.Exchange(ref lastCallbackTimestamp, now);
|
||||
if (prev != 0)
|
||||
{
|
||||
var gapMs = (int)((now - prev) * 1000 / Stopwatch.Frequency);
|
||||
int current;
|
||||
do
|
||||
{
|
||||
current = Volatile.Read(ref maxCallbackGapMs);
|
||||
if (gapMs <= current) break;
|
||||
} while (Interlocked.CompareExchange(ref maxCallbackGapMs, gapMs, current) != current);
|
||||
}
|
||||
}
|
||||
// Pull all interleaved float samples for the recorded channels into a reusable buffer.
|
||||
var samplesNeeded = e.SamplesPerBuffer * e.InputBuffers.Length;
|
||||
if (interleavedScratch.Length < samplesNeeded) interleavedScratch = new float[samplesNeeded];
|
||||
var written = e.GetAsInterleavedSamples(interleavedScratch);
|
||||
Interlocked.Add(ref bytesCaptured, written * sizeof(float));
|
||||
var interleaved = interleavedScratch;
|
||||
|
||||
// Frame count = total samples / channel count.
|
||||
var frames = written / Math.Max(1, recordChannelCount);
|
||||
var stereoFloats = frames * MixChannels;
|
||||
if (mixScratch.Length < stereoFloats) mixScratch = new float[stereoFloats];
|
||||
Array.Clear(mixScratch, 0, stereoFloats);
|
||||
|
||||
// Mix selected channel pairs into the stereo output. Each pair contributes its L/R to
|
||||
// the mix bus.
|
||||
List<int> pairs;
|
||||
lock (gate) pairs = activeChannelPairIndices;
|
||||
|
||||
if (pairs.Count == 0) return;
|
||||
|
||||
for (var f = 0; f < frames; f++)
|
||||
{
|
||||
var srcBase = f * recordChannelCount;
|
||||
var dstBase = f * MixChannels;
|
||||
float l = 0f, r = 0f;
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
var lCh = pair * 2;
|
||||
var rCh = pair * 2 + 1;
|
||||
if (lCh < recordChannelCount) l += interleaved[srcBase + lCh];
|
||||
if (rCh < recordChannelCount) r += interleaved[srcBase + rCh];
|
||||
}
|
||||
// Soft-limit-ish clamp at the encoder boundary; matches MixingEngine.
|
||||
if (l > 1f) { l = 1f; Interlocked.Increment(ref clippedSampleCount); }
|
||||
else if (l < -1f) { l = -1f; Interlocked.Increment(ref clippedSampleCount); }
|
||||
if (r > 1f) { r = 1f; Interlocked.Increment(ref clippedSampleCount); }
|
||||
else if (r < -1f) { r = -1f; Interlocked.Increment(ref clippedSampleCount); }
|
||||
mixScratch[dstBase] = l;
|
||||
mixScratch[dstBase + 1] = r;
|
||||
}
|
||||
|
||||
onMixedSamples(new ReadOnlyMemory<float>(mixScratch, 0, stereoFloats));
|
||||
}
|
||||
|
||||
/// <summary>Returns the names of all installed ASIO drivers, or an empty list if NAudio
|
||||
/// can't find any. Exposed for the App's driver picker UI.</summary>
|
||||
public static IReadOnlyList<string> EnumerateDriverNames()
|
||||
{
|
||||
try { return AsioOut.GetDriverNames().ToList(); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Briefly opens the named ASIO driver to query its channel counts, then disposes. Single
|
||||
/// driver instance held for ~50 ms while the COM object reads its channel info — does not
|
||||
/// claim the device for streaming. Returns (in,out) = (-1,-1) on any failure (driver not
|
||||
/// installed, busy with another app, etc.). Used by the App to populate channel-pair lists
|
||||
/// in ASIO mode without holding the driver open between user actions.
|
||||
/// </summary>
|
||||
public static (int inputChannels, int outputChannels) ProbeChannelCounts(string driverName)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var asio = new AsioOut(driverName);
|
||||
return (asio.DriverInputChannelCount, asio.DriverOutputChannelCount);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (-1, -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Microsoft.Win32;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// Public static helpers for the App layer to enumerate ASIO drivers and probe their channel
|
||||
/// counts and channel names without needing access to the internal
|
||||
/// <see cref="AsioCaptureBackend"/> / <see cref="AsioRenderBackend"/> implementation classes.
|
||||
/// These are read-only queries: opening the driver briefly to read its info, then closing —
|
||||
/// does NOT claim the device for streaming.
|
||||
/// </summary>
|
||||
public static class AsioDeviceProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// Names of all installed ASIO drivers. Tries several enumeration paths and merges results,
|
||||
/// because:
|
||||
/// • NAudio's built-in <c>AsioOut.GetDriverNames()</c> reads <c>HKLM\SOFTWARE\ASIO</c> in
|
||||
/// the registry view that matches the calling process. A 64-bit RemSound only sees the
|
||||
/// 64-bit hive; some ASIO drivers register only into the 32-bit <c>Wow6432Node</c> hive.
|
||||
/// • A few drivers register under HKCU instead of HKLM.
|
||||
/// We scan both views and both hives, merge results (case-insensitive de-dup on the
|
||||
/// registry key name and the human-friendly Description), and return the descriptions.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> EnumerateDriverNames()
|
||||
{
|
||||
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
foreach (var n in AsioOut.GetDriverNames()) names.Add(n);
|
||||
}
|
||||
catch { /* ignore — fall through to manual scan */ }
|
||||
|
||||
// Manual scan covers cases NAudio's built-in helper misses.
|
||||
AddFromRegistry(RegistryHive.LocalMachine, RegistryView.Registry64, names);
|
||||
AddFromRegistry(RegistryHive.LocalMachine, RegistryView.Registry32, names);
|
||||
AddFromRegistry(RegistryHive.CurrentUser, RegistryView.Registry64, names);
|
||||
AddFromRegistry(RegistryHive.CurrentUser, RegistryView.Registry32, names);
|
||||
|
||||
return names.ToList();
|
||||
}
|
||||
|
||||
private static void AddFromRegistry(RegistryHive hive, RegistryView view, HashSet<string> names)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var baseKey = RegistryKey.OpenBaseKey(hive, view);
|
||||
using var asioKey = baseKey.OpenSubKey(@"SOFTWARE\ASIO");
|
||||
if (asioKey is null) return;
|
||||
foreach (var subKeyName in asioKey.GetSubKeyNames())
|
||||
{
|
||||
using var sub = asioKey.OpenSubKey(subKeyName);
|
||||
if (sub is null) continue;
|
||||
// Most drivers store a friendly "Description" value; if absent, the subkey name
|
||||
// itself is what NAudio uses.
|
||||
var description = sub.GetValue("Description") as string;
|
||||
names.Add(string.IsNullOrWhiteSpace(description) ? subKeyName : description);
|
||||
}
|
||||
}
|
||||
catch { /* ignore — that hive/view combo unavailable, fine */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probes the named ASIO driver for full info (channel counts + per-channel names). Briefly
|
||||
/// opens the driver, reads metadata, disposes. Returns a result with empty arrays + −1
|
||||
/// counts on any failure.
|
||||
/// </summary>
|
||||
public static AsioDriverProbeResult ProbeDriverInfo(string driverName)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var asio = new AsioOut(driverName);
|
||||
var inCount = asio.DriverInputChannelCount;
|
||||
var outCount = asio.DriverOutputChannelCount;
|
||||
var inNames = new List<string>(Math.Max(0, inCount));
|
||||
for (var i = 0; i < inCount; i++)
|
||||
{
|
||||
try { inNames.Add(asio.AsioInputChannelName(i)); }
|
||||
catch { inNames.Add($"Input {i + 1}"); }
|
||||
}
|
||||
var outNames = new List<string>(Math.Max(0, outCount));
|
||||
for (var i = 0; i < outCount; i++)
|
||||
{
|
||||
try { outNames.Add(asio.AsioOutputChannelName(i)); }
|
||||
catch { outNames.Add($"Output {i + 1}"); }
|
||||
}
|
||||
return new AsioDriverProbeResult(inCount, outCount, inNames, outNames);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new AsioDriverProbeResult(-1, -1, [], []);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backwards-compatibility shim around <see cref="ProbeDriverInfo"/> for callers that only
|
||||
/// need channel counts.
|
||||
/// </summary>
|
||||
public static (int inputChannels, int outputChannels) ProbeChannelCounts(string driverName)
|
||||
{
|
||||
var info = ProbeDriverInfo(driverName);
|
||||
return (info.InputChannelCount, info.OutputChannelCount);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AsioDriverProbeResult(
|
||||
int InputChannelCount,
|
||||
int OutputChannelCount,
|
||||
IReadOnlyList<string> InputChannelNames,
|
||||
IReadOnlyList<string> OutputChannelNames);
|
||||
@@ -0,0 +1,595 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
|
||||
// PcmPack is in RemSound.Core (used by both Sender and Receiver).
|
||||
|
||||
/// <summary>
|
||||
/// Captures from one or more Windows audio devices via WASAPI (loopback for output devices,
|
||||
/// direct capture for input devices), mixes them into a single 48 kHz stereo float stream
|
||||
/// through <see cref="MixingEngine"/>, encodes (PCM 24-bit or Opus), and sends to a configurable
|
||||
/// set of UDP receivers.
|
||||
///
|
||||
/// The mixing engine owns the capture lifecycle and the per-source silence keepalive (needed on
|
||||
/// USB audio interfaces whose loopback callbacks otherwise stall when no app is rendering — see
|
||||
/// naudio/NAudio#1110). AudioSender just wires the mixer's mixed-sample callback into the
|
||||
/// existing PCM/Opus encode + UDP path.
|
||||
///
|
||||
/// Threading model: the mixer's tick task delivers 10 ms frames here on its own thread; this
|
||||
/// class accumulates into PCM 5 ms or Opus 10/20 ms frames and dispatches over UDP. No
|
||||
/// cross-thread synchronization other than reading a few volatile flags (codec, mute,
|
||||
/// receiver list).
|
||||
/// </summary>
|
||||
public sealed class AudioSender : IDisposable
|
||||
{
|
||||
// PCM frame size is configurable via SendRate. Standard = 5 ms (240 samples = 1440 bytes,
|
||||
// single UDP packet under MaxAudioPayloadBytes=1454). Tight = 2.5 ms (120 samples = 720
|
||||
// bytes, also single packet). Tight mode adds nothing structurally — same packet shape,
|
||||
// just half-size — so the receive-side multipart assembler stays a no-op.
|
||||
private const int MixChannels = 2;
|
||||
private const int OpusBitrateLan = 192_000;
|
||||
private const int PcmStandardSamplesPerChannel = 240; // 5 ms
|
||||
private const int PcmTightSamplesPerChannel = 120; // 2.5 ms
|
||||
|
||||
// Mutable PCM frame parameters — updated by SetSendRate. Keep them volatile because the
|
||||
// hot-path read happens on the audio thread while writes come from the UI thread.
|
||||
private volatile int pcmFrameSamplesPerChannel = PcmStandardSamplesPerChannel;
|
||||
internal int PcmFrameStereoSamples => pcmFrameSamplesPerChannel * MixChannels;
|
||||
internal int PcmFrameSamplesPerChannel => pcmFrameSamplesPerChannel;
|
||||
|
||||
private readonly object configGate = new();
|
||||
private ICaptureBackend engine;
|
||||
private IReadOnlyList<CaptureSourceSpec> pendingSources = [];
|
||||
private readonly UdpClient udp;
|
||||
// Two lanes. defaultLane carries every output in the three classic modes (Mixed route).
|
||||
// In BothIndependent mode defaultLane carries WASAPI-only audio (route WasapiLane) and
|
||||
// asioLane carries ASIO-only audio (route AsioLane), each producing its own UDP stream
|
||||
// tagged with the matching Lane byte so the receiver routes them to per-lane
|
||||
// IWaveProvider surfaces. We always construct both lanes — the asio lane sits idle
|
||||
// (no capture child wired to it) in classic modes and the memory cost is trivial.
|
||||
private readonly SenderLane defaultLane;
|
||||
private readonly SenderLane asioLane;
|
||||
// Persistent AsioCaptureBackend that survives audio-mode changes. The composite borrows
|
||||
// a reference to it; mode rebuilds rewire its callback (via SetCallback) rather than
|
||||
// tearing it down and reopening the driver. This avoids Audient (and similar single-
|
||||
// client drivers) hanging the audio thread for ~5 s on rapid close+reopen — which had
|
||||
// been crashing the laptop on every "switch between Both and AsioOnly" attempt.
|
||||
// Lazily created when first needed, disposed when transitioning to WasapiOnly OR when
|
||||
// the user picks a different ASIO driver entirely. The Action<...> stub is a deliberate
|
||||
// placeholder that gets immediately swapped via SetCallback in EnsurePersistentAsio.
|
||||
private AsioCaptureBackend? persistentAsio;
|
||||
private string? persistentAsioDriverName;
|
||||
|
||||
/// <summary>Optional diagnostic sink. Set by callers (typically the App) before Start to receive
|
||||
/// human-readable status strings ("capture started…", "capture stopped with error…", etc.).</summary>
|
||||
public Action<string>? Diagnostic
|
||||
{
|
||||
get => diagnostic;
|
||||
set => diagnostic = value;
|
||||
}
|
||||
private Action<string>? diagnostic;
|
||||
|
||||
// Per-stream state (streamId, audioSequence, frame accumulator, Opus encoder, PCM frame id,
|
||||
// format-resend timer) now lives on each SenderLane. This file kept its monolithic shape
|
||||
// through Phase 1/2 — the BothIndependent refactor required splitting "stuff that belongs
|
||||
// to one outbound stream" from "shared infrastructure". The accumulator/outbound scratch/
|
||||
// streamId/sequence counters are all per-lane; the UDP socket, codec config, mute flag,
|
||||
// engine and stats stay here. See <see cref="SenderLane"/> for the per-stream hot path.
|
||||
|
||||
private readonly Stopwatch uptime = new();
|
||||
private volatile AudioTransportCodec codec = AudioTransportCodec.Pcm;
|
||||
private volatile int opusFrameMs = 10; // only meaningful when codec == Opus
|
||||
private volatile bool muted;
|
||||
private IPEndPoint[] receivers = [];
|
||||
private long packetsSent;
|
||||
private long bytesSent;
|
||||
|
||||
// Internal accessor so SenderLane can read tight-latency without exposing the field
|
||||
// publicly. Codec, OpusFrameMilliseconds and IsMuted are already exposed publicly below
|
||||
// and re-used directly by the lane.
|
||||
internal bool IsTightLatencyEnabled => tightLatencyEnabled;
|
||||
|
||||
// Hot-path timing instrumentation. Both lanes update these on every emit; the SNAP
|
||||
// timer reads + resets them once per second. Used to split observed inter-packet jitter
|
||||
// between "our code is slow" vs "the kernel is slow" vs "the network is slow".
|
||||
// maxEmitTicks = Stopwatch ticks for the WIDEST observation of SenderLane's
|
||||
// OnMixedSamples (encode + scratch + SendToAll). If this is in
|
||||
// the multi-ms range, our encode pipeline is the bottleneck.
|
||||
// maxSendCallTicks = Stopwatch ticks for the WIDEST single udp.Client.SendTo call.
|
||||
// If this is in the multi-ms range, the kernel TX buffer / NIC
|
||||
// driver / send-socket contention is the bottleneck.
|
||||
// Both are reset on each Take() so the SNAP gets per-second peaks.
|
||||
private long maxEmitTicks;
|
||||
private long maxSendCallTicks;
|
||||
internal void RecordEmitTicks(long ticks)
|
||||
{
|
||||
long current;
|
||||
do { current = Volatile.Read(ref maxEmitTicks); }
|
||||
while (ticks > current && Interlocked.CompareExchange(ref maxEmitTicks, ticks, current) != current);
|
||||
}
|
||||
internal void RecordSendCallTicks(long ticks)
|
||||
{
|
||||
long current;
|
||||
do { current = Volatile.Read(ref maxSendCallTicks); }
|
||||
while (ticks > current && Interlocked.CompareExchange(ref maxSendCallTicks, ticks, current) != current);
|
||||
}
|
||||
public int TakeMaxEmitMs() => (int)(Interlocked.Exchange(ref maxEmitTicks, 0) * 1000 / Stopwatch.Frequency);
|
||||
public int TakeMaxSendCallMs() => (int)(Interlocked.Exchange(ref maxSendCallTicks, 0) * 1000 / Stopwatch.Frequency);
|
||||
|
||||
// === inbound dispatch (relay-mode) ===
|
||||
// The send socket is normally write-only, but in relay-mode the same socket is what
|
||||
// catches return packets — the relay forwards traffic into our NAT pinhole, which lives on
|
||||
// this socket's ephemeral port. An optional inbound-packet callback lets the App route
|
||||
// those packets into the receiver pipeline (audio) or the heartbeat service.
|
||||
// Existing LAN peer-to-peer behaviour is unchanged: nothing inbound arrives at this socket
|
||||
// from a LAN peer because LAN peers send to the receiver's well-known port directly.
|
||||
private CancellationTokenSource? inboundCts;
|
||||
private Thread? inboundThread;
|
||||
private long inboundPackets;
|
||||
|
||||
/// <summary>
|
||||
/// Optional callback invoked for each UDP datagram that arrives at this sender's socket.
|
||||
/// Buffer is owned by the receive thread — copy what you keep. Length is the byte count
|
||||
/// (the buffer may be larger). Remote is the sender of the packet (typically a relay).
|
||||
/// Set this before <see cref="StartReceiving"/> is called.
|
||||
/// </summary>
|
||||
public Action<byte[], int, IPEndPoint>? OnInboundPacket { get; set; }
|
||||
|
||||
public AudioSender()
|
||||
{
|
||||
udp = new UdpClient(AddressFamily.InterNetwork);
|
||||
udp.Client.SendBufferSize = 256 * 1024;
|
||||
udp.Client.ReceiveBufferSize = 256 * 1024;
|
||||
// Explicit bind to port 0 (OS picks an ephemeral). Two reasons:
|
||||
// 1. ReceiveFrom on an unbound UDP socket throws SocketException (WSAEINVAL) on
|
||||
// Windows — the receive thread we start below would then CPU-spin in its
|
||||
// catch/continue loop. Binding up front makes ReceiveFrom block normally for
|
||||
// data instead.
|
||||
// 2. Same NAT pinhole is shared between send and receive — relay mode requires
|
||||
// this; LAN peer-to-peer is unaffected (we still send from this port, peer just
|
||||
// sends to its own well-known port as before).
|
||||
udp.Client.Bind(new IPEndPoint(IPAddress.Any, 0));
|
||||
defaultLane = new SenderLane(this, opusFrameMs, OpusBitrateLan);
|
||||
asioLane = new SenderLane(this, opusFrameMs, OpusBitrateLan);
|
||||
// WasapiOnly at startup — no ASIO needed yet, so persistentAsio stays null.
|
||||
currentAudioMode = AudioMode.WasapiOnly;
|
||||
currentAsioDriverName = null;
|
||||
engine = new CompositeCaptureBackend(currentAudioMode, currentAsioDriverName, defaultLane.OnMixedSamples, asioLane.OnMixedSamples, persistentAsio, msg => diagnostic?.Invoke(msg), useTightLatencyWasapi: false);
|
||||
}
|
||||
|
||||
// Held so SetTightLatency can rebuild the composite with the same mode/driver.
|
||||
private AudioMode currentAudioMode;
|
||||
private string? currentAsioDriverName;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the audio backend mode and (when ASIO is involved) the driver name. The composite is
|
||||
/// rebuilt to match. Two reachable pipeline shapes today:
|
||||
/// * WasapiOnly: MixingEngine direct, no ASIO code in the path. Lowest latency for users
|
||||
/// without ASIO.
|
||||
/// * BothIndependent: WASAPI MixingEngine + persistent AsioCaptureBackend running side by
|
||||
/// side, each on its own SenderLane (own streamId, own UDP stream). No mix loop, no tee.
|
||||
/// Each lane keeps its native latency.
|
||||
/// Legacy <c>AudioMode.AsioOnly</c> and <c>AudioMode.Both</c> are tolerated (the composite
|
||||
/// coerces them) but no UI path produces them any more. If running, previously-pending
|
||||
/// sources are re-applied automatically.
|
||||
/// </summary>
|
||||
public void SetAudioMode(AudioMode mode, string? asioDriverName)
|
||||
{
|
||||
lock (configGate)
|
||||
{
|
||||
currentAudioMode = mode;
|
||||
currentAsioDriverName = asioDriverName;
|
||||
// Lane route assignment. WasapiOnly: only defaultLane is active, carrying Mixed.
|
||||
// BothIndependent: defaultLane carries the WASAPI lane, asioLane carries the ASIO
|
||||
// lane. SetRoute rotates each lane's streamId so the receiver opens a fresh
|
||||
// session under the new Lane tag — old session drains naturally on its 4-second
|
||||
// prune. Legacy AsioOnly / Both can't be produced by the UI any more; if they
|
||||
// arrive (in-flight callers, future call sites) we treat them as BothIndependent
|
||||
// for routing purposes so the streams still carry distinct Lane tags.
|
||||
if (mode != AudioMode.WasapiOnly)
|
||||
{
|
||||
defaultLane.SetRoute(RenderRoute.WasapiLane);
|
||||
asioLane.SetRoute(RenderRoute.AsioLane);
|
||||
}
|
||||
else
|
||||
{
|
||||
defaultLane.SetRoute(RenderRoute.Mixed);
|
||||
asioLane.SetRoute(RenderRoute.Mixed); // idle; no callbacks will fire on it
|
||||
}
|
||||
EnsurePersistentAsioLocked();
|
||||
RebuildEngineLocked();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Make sure <see cref="persistentAsio"/> matches the current mode + driver. Created
|
||||
/// fresh when first transitioning into an ASIO-using mode; reused across subsequent
|
||||
/// mode changes that keep the same driver; disposed when transitioning to WasapiOnly
|
||||
/// (no ASIO) or when the user picks a different driver. The persistent instance is
|
||||
/// loaned to the composite via the constructor; the composite borrows but doesn't
|
||||
/// dispose, so the underlying ASIO driver handle stays open across engine rebuilds.
|
||||
/// Caller must hold <see cref="configGate"/>. The callback is also rewired here based
|
||||
/// on which lane should receive ASIO audio in the new mode.
|
||||
/// </summary>
|
||||
private void EnsurePersistentAsioLocked()
|
||||
{
|
||||
var willUseAsio = currentAudioMode != AudioMode.WasapiOnly
|
||||
&& !string.IsNullOrEmpty(currentAsioDriverName);
|
||||
|
||||
if (!willUseAsio)
|
||||
{
|
||||
// Mode no longer uses ASIO. Dispose the persistent instance so the driver
|
||||
// releases (other apps may want it).
|
||||
if (persistentAsio is not null)
|
||||
{
|
||||
try { persistentAsio.Dispose(); } catch { /* ignore */ }
|
||||
persistentAsio = null;
|
||||
persistentAsioDriverName = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Need ASIO. Reuse if the driver matches; rebuild otherwise (rare — only when the
|
||||
// user picks a different driver in the dropdown).
|
||||
if (persistentAsio is null || persistentAsioDriverName != currentAsioDriverName)
|
||||
{
|
||||
if (persistentAsio is not null)
|
||||
{
|
||||
try { persistentAsio.Dispose(); } catch { /* ignore */ }
|
||||
}
|
||||
persistentAsio = new AsioCaptureBackend(
|
||||
currentAsioDriverName!,
|
||||
_ => { /* placeholder, replaced by SetCallback below */ },
|
||||
msg => diagnostic?.Invoke($"asio: {msg}"));
|
||||
persistentAsioDriverName = currentAsioDriverName;
|
||||
}
|
||||
|
||||
// Wire the callback to the right lane for the current mode. WasapiOnly never reaches
|
||||
// here (willUseAsio is false above). BothIndependent is the only ASIO-using mode the
|
||||
// UI can produce, and it routes ASIO into the dedicated AsioLane. Legacy AsioOnly is
|
||||
// tolerated by sending into defaultLane (which carries RenderRoute.Mixed in non-
|
||||
// BothIndependent setups).
|
||||
persistentAsio.SetCallback(
|
||||
currentAudioMode == AudioMode.BothIndependent
|
||||
? asioLane.OnMixedSamples
|
||||
: defaultLane.OnMixedSamples);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// (Re)create the composite backend with the current audio-mode + asio-driver-name +
|
||||
/// tight-latency-WASAPI flag. Caller must hold <c>configGate</c>. Preserves the running
|
||||
/// state — if the engine was running before, restart it with the same source list.
|
||||
/// The persistent ASIO instance is passed in by reference so the composite borrows
|
||||
/// rather than creates+disposes it; that's what keeps the driver open across rebuilds.
|
||||
/// </summary>
|
||||
private void RebuildEngineLocked()
|
||||
{
|
||||
var wasRunning = engine.IsRunning;
|
||||
try { engine.Stop(); } catch { /* ignore */ }
|
||||
try { engine.Dispose(); } catch { /* ignore */ }
|
||||
engine = new CompositeCaptureBackend(
|
||||
currentAudioMode,
|
||||
currentAsioDriverName,
|
||||
defaultLane.OnMixedSamples,
|
||||
asioLane.OnMixedSamples,
|
||||
persistentAsio,
|
||||
msg => diagnostic?.Invoke(msg),
|
||||
useTightLatencyWasapi: tightLatencyEnabled);
|
||||
if (wasRunning && pendingSources.Count > 0)
|
||||
{
|
||||
engine.Start(pendingSources);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsAsioBackend => engine is CompositeCaptureBackend;
|
||||
|
||||
/// <summary>Updates the PCM frame size based on the user's "Send rate" choice. For Opus,
|
||||
/// frame size is set via <see cref="ConfigureCodec"/>'s opusFrameMs parameter (the App
|
||||
/// halves it when SendRate is Tight). On a frame-size change, resets the accumulator and
|
||||
/// stream id so the receiver opens a fresh session at the new format.</summary>
|
||||
public void SetSendRate(SendRate rate)
|
||||
{
|
||||
lock (configGate)
|
||||
{
|
||||
var newSamples = rate == SendRate.Tight ? PcmTightSamplesPerChannel : PcmStandardSamplesPerChannel;
|
||||
if (newSamples == pcmFrameSamplesPerChannel) return;
|
||||
pcmFrameSamplesPerChannel = newSamples;
|
||||
// Both lanes need to rotate streamId + reset accumulator on a frame-size change.
|
||||
// The asio lane is idle in classic modes (no producer feeding it) so the reset is
|
||||
// harmless there; in BothIndependent both lanes are active and both must roll.
|
||||
defaultLane.OnPcmFrameSizeChanged();
|
||||
asioLane.OnPcmFrameSizeChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tight-latency mode toggle. Affects two things:
|
||||
/// * ASIO-only PCM: every incoming ASIO buffer is emitted directly as a single packet
|
||||
/// instead of being accumulated to the PCM frame size — saves ~frame_size_ms/2 of
|
||||
/// average send-side latency. ProcessPcm reads <c>tightLatencyEnabled</c> directly.
|
||||
/// * WasapiOnly with single source: rebuilds the capture backend as
|
||||
/// <see cref="PushModeWasapiBackend"/> instead of <see cref="MixingEngine"/>. The WASAPI
|
||||
/// capture event drives the encode/UDP-send pipeline directly, eliminating the ~6 ms
|
||||
/// of Stopwatch+WaitHandle scheduler jitter that <see cref="MixingEngine"/>'s mix tick
|
||||
/// adds. Especially important at high device sample rates (96 kHz EVO8 etc.) where the
|
||||
/// in-tick resampler stage compounds the jitter. <see cref="CompositeCaptureBackend"/>
|
||||
/// decides whether push-mode actually applies based on source count and mode.
|
||||
/// No effect on Opus accumulation (Opus needs fixed frame sizes) or AsioOnly's WASAPI
|
||||
/// (there's no WASAPI source). Sender-side only as of Phase 3 (2026-05-06): the
|
||||
/// receiver no longer has a resampler to bypass.</summary>
|
||||
public void SetTightLatency(bool enabled)
|
||||
{
|
||||
lock (configGate)
|
||||
{
|
||||
if (tightLatencyEnabled == enabled) return;
|
||||
tightLatencyEnabled = enabled;
|
||||
RebuildEngineLocked();
|
||||
}
|
||||
}
|
||||
private volatile bool tightLatencyEnabled;
|
||||
|
||||
public bool IsRunning => engine.IsRunning;
|
||||
public long CaptureCallbacks => engine.TotalCaptureCallbacks;
|
||||
public long CaptureBytes => engine.TotalCaptureBytes;
|
||||
/// <summary>Largest gap between capture callbacks since the last call. Resets on read.
|
||||
/// Use this in periodic diagnostics — if it spikes well above the audio-buffer period
|
||||
/// (e.g. 19 ms when the period should be ≤ 5 ms), the audio capture thread is being
|
||||
/// stalled by GC, USB, or scheduler issues, which produces audible discontinuities the
|
||||
/// receiver can't detect (because no packets are lost — they just contain audio with
|
||||
/// holes in it).</summary>
|
||||
public int TakeMaxCaptureCallbackGapMs() => engine.TakeMaxCallbackGapMs();
|
||||
public string? CaptureFormatDescription => engine.FirstCaptureFormatDescription;
|
||||
public string? LastCaptureError => engine.FirstCaptureLastError;
|
||||
public long ClippedSampleCount => engine.ClippedSampleCount;
|
||||
public AudioTransportCodec Codec => codec;
|
||||
public int OpusFrameMilliseconds => opusFrameMs;
|
||||
|
||||
/// <summary>
|
||||
/// Atomically set the codec and (for Opus) the frame size. Resets stream identity and the
|
||||
/// frame accumulator so the receiver sees the new format from the next packet onward. Both
|
||||
/// parameters are taken together because changing only one would briefly send malformed
|
||||
/// frames at the encoder boundary.
|
||||
/// </summary>
|
||||
public void ConfigureCodec(AudioTransportCodec newCodec, int newOpusFrameMs = 10)
|
||||
{
|
||||
var clampedFrameMs = Math.Clamp(newOpusFrameMs, 5, 60);
|
||||
if (codec == newCodec && (newCodec != AudioTransportCodec.Opus || opusFrameMs == clampedFrameMs))
|
||||
{
|
||||
return;
|
||||
}
|
||||
lock (configGate)
|
||||
{
|
||||
codec = newCodec;
|
||||
opusFrameMs = clampedFrameMs;
|
||||
// Rebuild both lanes' encoders + rotate their streamIds. Same idle-lane rationale
|
||||
// as SetSendRate — harmless when the asio lane has no producer; necessary when it
|
||||
// does (BothIndependent).
|
||||
defaultLane.OnCodecChanged(newCodec, clampedFrameMs);
|
||||
asioLane.OnCodecChanged(newCodec, clampedFrameMs);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsMuted { get => muted; set => muted = value; }
|
||||
public long PacketsSent => Interlocked.Read(ref packetsSent);
|
||||
public long BytesSent => Interlocked.Read(ref bytesSent);
|
||||
public TimeSpan Uptime => uptime.Elapsed;
|
||||
|
||||
/// <summary>
|
||||
/// Friendly summary of currently-active sources for diagnostic columns. Returns
|
||||
/// "(none)" when nothing is configured, "(N sources)" when there are 4+ — the snapshot log
|
||||
/// column is fixed-width-ish and a long join becomes unreadable past 3 sources.
|
||||
/// </summary>
|
||||
public string CaptureDeviceName
|
||||
{
|
||||
get
|
||||
{
|
||||
var names = engine.ActiveSourceNames;
|
||||
if (names.Count == 0) return "(none)";
|
||||
if (names.Count <= 3) return string.Join(", ", names);
|
||||
return $"({names.Count} sources)";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Set the destinations to which packets are sent. Live-updateable.</summary>
|
||||
public void SetReceivers(IEnumerable<IPEndPoint> endpoints)
|
||||
{
|
||||
var list = endpoints.ToArray();
|
||||
Volatile.Write(ref receivers, list);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the list of capture sources to mix. Each spec identifies a WASAPI device + whether
|
||||
/// it's a loopback (output device, system audio) or direct input (mic, line-in). Order does
|
||||
/// not matter — sources are summed equally.
|
||||
/// </summary>
|
||||
public void Configure(IReadOnlyList<CaptureSourceSpec> sources)
|
||||
{
|
||||
pendingSources = sources;
|
||||
if (engine.IsRunning)
|
||||
{
|
||||
// Live add/remove via NAudio's MixingSampleProvider — mix loop never pauses,
|
||||
// streamId stays the same, receiver doesn't see a new stream session, no underrun.
|
||||
engine.UpdateSources(sources);
|
||||
}
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (engine.IsRunning) return;
|
||||
StartEngineWithCurrentSources();
|
||||
}
|
||||
|
||||
private void StartEngineWithCurrentSources()
|
||||
{
|
||||
if (pendingSources.Count == 0)
|
||||
{
|
||||
diagnostic?.Invoke("sender: start requested but no sources configured");
|
||||
return;
|
||||
}
|
||||
|
||||
defaultLane.ResetForStart();
|
||||
asioLane.ResetForStart();
|
||||
Interlocked.Exchange(ref packetsSent, 0);
|
||||
Interlocked.Exchange(ref bytesSent, 0);
|
||||
uptime.Restart();
|
||||
engine.Start(pendingSources);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
engine.Stop();
|
||||
uptime.Stop();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start a background thread reading inbound packets from this sender's socket and
|
||||
/// dispatching them to <see cref="OnInboundPacket"/>. Idempotent — safe to call repeatedly.
|
||||
/// Used in relay mode so heartbeat replies and audio coming back through the relay
|
||||
/// (which arrive at the sender's NAT pinhole, not the receiver's well-known port) get
|
||||
/// routed into the right pipelines. No-op for pure LAN peer-to-peer setups.
|
||||
/// </summary>
|
||||
public void StartReceiving()
|
||||
{
|
||||
lock (configGate)
|
||||
{
|
||||
if (inboundThread is { IsAlive: true }) return;
|
||||
inboundCts = new CancellationTokenSource();
|
||||
var token = inboundCts.Token;
|
||||
inboundThread = new Thread(() => InboundReceiveLoop(token))
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "RemSound.SenderReceive",
|
||||
};
|
||||
inboundThread.Start();
|
||||
}
|
||||
}
|
||||
|
||||
private void InboundReceiveLoop(CancellationToken token)
|
||||
{
|
||||
var buffer = new byte[2048];
|
||||
EndPoint anyEndpoint = new IPEndPoint(IPAddress.Any, 0);
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
int received;
|
||||
try
|
||||
{
|
||||
received = udp.Client.ReceiveFrom(buffer, 0, buffer.Length, SocketFlags.None, ref anyEndpoint);
|
||||
}
|
||||
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.Interrupted) { break; }
|
||||
catch (ObjectDisposedException) { break; }
|
||||
catch (SocketException) { continue; }
|
||||
catch (OperationCanceledException) { break; }
|
||||
|
||||
if (received <= 0) continue;
|
||||
if (anyEndpoint is not IPEndPoint remote) continue;
|
||||
Interlocked.Increment(ref inboundPackets);
|
||||
|
||||
try
|
||||
{
|
||||
OnInboundPacket?.Invoke(buffer, received, remote);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
diagnostic?.Invoke($"sender inbound dispatch threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send an arbitrary datagram on this sender's UDP socket. Used by the heartbeat service
|
||||
/// in relay mode so its packets share the same NAT pinhole as audio. Returns false if the
|
||||
/// send failed.
|
||||
/// </summary>
|
||||
public bool SendVia(byte[] data, int length, IPEndPoint destination)
|
||||
{
|
||||
try
|
||||
{
|
||||
udp.Send(data, length, destination);
|
||||
return true;
|
||||
}
|
||||
catch (SocketException) { return false; }
|
||||
catch (ObjectDisposedException) { return false; }
|
||||
}
|
||||
|
||||
/// <summary>Cumulative inbound packets received on this sender's socket. Mostly zero
|
||||
/// outside relay mode.</summary>
|
||||
public long InboundPackets => Interlocked.Read(ref inboundPackets);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
try { inboundCts?.Cancel(); } catch { /* ignore */ }
|
||||
try { inboundThread?.Join(500); } catch { /* ignore */ }
|
||||
engine.Dispose();
|
||||
// Dispose the persistent ASIO LAST, after the engine that was borrowing it. The
|
||||
// composite's Dispose doesn't touch the persistent instance (it borrowed it); we
|
||||
// own it here and close the driver as part of app shutdown.
|
||||
try { persistentAsio?.Dispose(); } catch { /* ignore */ }
|
||||
persistentAsio = null;
|
||||
udp.Dispose();
|
||||
}
|
||||
|
||||
// === wire path (shared across all lanes) ===
|
||||
|
||||
/// <summary>
|
||||
/// Emit a fully-constructed packet to every configured receiver. Per-lane code in
|
||||
/// <see cref="SenderLane"/> builds the header + payload (in its stack/pre-allocated
|
||||
/// outboundScratch) and calls this; we forward the span straight into the socket's
|
||||
/// span-aware Send overload so the audio thread never allocates anything in the hot
|
||||
/// path. The pre-2026-05-11 implementation did packet.ToArray() per send, which on
|
||||
/// ASIO tight-latency throughput (~750 packets/sec per lane × two lanes in
|
||||
/// BothIndependent) was a steady ~2 MB/sec of small-byte-array Gen 0 allocations and
|
||||
/// drove visible packet-emission jitter via GC pauses. The span overload eliminates
|
||||
/// that entire allocation stream.
|
||||
///
|
||||
/// Single point of outbound socket use means both lanes share the same NAT pinhole
|
||||
/// and stats. The send-buffer-full or kernel-mutex contention between two threads
|
||||
/// sending on the same UDP socket is microseconds in practice and not the source of
|
||||
/// the ms-scale jitter we observe; the per-packet allocation was.
|
||||
///
|
||||
/// UDP failures per-receiver are swallowed by design — UDP is unreliable and one
|
||||
/// peer dropping shouldn't disturb the others.
|
||||
/// </summary>
|
||||
internal void SendToAll(ReadOnlySpan<byte> packet)
|
||||
{
|
||||
var targets = Volatile.Read(ref receivers);
|
||||
if (targets.Length == 0) return;
|
||||
|
||||
// Use Socket.SendTo with the span overload — UdpClient's span-Send signature is
|
||||
// .NET 6+. Going via Client (the underlying Socket) avoids one wrapper layer too.
|
||||
var packetLen = packet.Length;
|
||||
// Measure the kernel-side time of just the SendTo call when diagnostics are enabled.
|
||||
// If this number spikes, the bottleneck is the TX path (kernel buffer pressure, NIC,
|
||||
// single-socket cross-thread contention) rather than our encode pipeline. Hoisted
|
||||
// out of the per-target loop so a multi-peer broadcast pays one branch instead of N.
|
||||
var diag = RemSound.Core.DiagnosticsGate.Enabled;
|
||||
foreach (var target in targets)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (diag)
|
||||
{
|
||||
var sendStart = Stopwatch.GetTimestamp();
|
||||
udp.Client.SendTo(packet, target);
|
||||
RecordSendCallTicks(Stopwatch.GetTimestamp() - sendStart);
|
||||
}
|
||||
else
|
||||
{
|
||||
udp.Client.SendTo(packet, target);
|
||||
}
|
||||
Interlocked.Increment(ref packetsSent);
|
||||
Interlocked.Add(ref bytesSent, packetLen);
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
// Single-packet failures are a non-event; UDP is unreliable by design.
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using NAudio.CoreAudioApi;
|
||||
using NAudio.Wave;
|
||||
using NAudio.Wave.SampleProviders;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// One capture source feeding the mixer. Wraps a single <see cref="WasapiCapture"/> (loopback or
|
||||
/// direct input) and produces 48 kHz stereo float samples through an NAudio sample-provider chain.
|
||||
///
|
||||
/// Pipeline:
|
||||
/// WasapiCapture (event-sync, 10 ms buffer)
|
||||
/// → BufferedWaveProvider (250 ms ring; ReadFully=true pads with silence on underflow,
|
||||
/// DiscardOnBufferOverflow=true drops oldest on overflow)
|
||||
/// → ToSampleProvider (bytes → floats)
|
||||
/// → WdlResamplingSampleProvider (any rate → 48 kHz)
|
||||
/// → StereoMixDown (any channel layout → stereo)
|
||||
///
|
||||
/// The <see cref="Provider"/> exposes that final 48 kHz stereo float stream so the mixing engine
|
||||
/// can plug it into NAudio's <see cref="MixingSampleProvider"/>.
|
||||
///
|
||||
/// Threading: NAudio's capture event runs on its own dedicated thread. We push samples into a
|
||||
/// thread-safe BufferedWaveProvider; the mixer's pull thread reads from the sample-provider
|
||||
/// chain. Standard NAudio idiom — well-tested and avoids hand-rolling SPSC ring buffers.
|
||||
///
|
||||
/// Per-source clock drift across independent audio devices IS unavoidable
|
||||
/// (https://rogueamoeba.com/support/knowledgebase/?showArticle=Loopback-AggregateDeviceHandling)
|
||||
/// but the 250 ms ring + automatic discard-on-overflow tolerates it for realistic session
|
||||
/// lengths. A proper drift-correcting micro-resample is a future addition.
|
||||
/// </summary>
|
||||
internal sealed class CaptureSource : IDisposable
|
||||
{
|
||||
private const int MixSampleRate = 48000;
|
||||
private const int MixChannels = 2;
|
||||
private const int CaptureBufferMs = 10;
|
||||
private const int RingBufferMs = 250;
|
||||
|
||||
private readonly WasapiCapture capture;
|
||||
private readonly BufferedWaveProvider buffer;
|
||||
private readonly Action<string>? onDiagnostic;
|
||||
private long callbackCount;
|
||||
private long bytesCaptured;
|
||||
private string? lastError;
|
||||
|
||||
public string Name { get; }
|
||||
public CaptureKind Kind { get; }
|
||||
public string DeviceId { get; }
|
||||
public ISampleProvider Provider { get; }
|
||||
public string CaptureFormatDescription { get; }
|
||||
|
||||
public long CallbackCount => Interlocked.Read(ref callbackCount);
|
||||
public long BytesCaptured => Interlocked.Read(ref bytesCaptured);
|
||||
public string? LastError => lastError;
|
||||
public int BufferedMilliseconds =>
|
||||
(int)(buffer.BufferedDuration.TotalMilliseconds);
|
||||
|
||||
public CaptureSource(MMDevice device, CaptureKind kind, string displayName, Action<string>? onDiagnostic = null)
|
||||
{
|
||||
Name = displayName;
|
||||
Kind = kind;
|
||||
DeviceId = device.ID;
|
||||
this.onDiagnostic = onDiagnostic;
|
||||
|
||||
capture = kind == CaptureKind.Loopback
|
||||
? new LowLatencyWasapiLoopbackCapture(device, audioBufferMilliseconds: CaptureBufferMs)
|
||||
: new WasapiCapture(device, useEventSync: true, audioBufferMillisecondsLength: CaptureBufferMs);
|
||||
|
||||
var captureFormat = capture.WaveFormat;
|
||||
CaptureFormatDescription =
|
||||
$"{captureFormat.SampleRate} Hz, {captureFormat.Channels} ch, {captureFormat.BitsPerSample}-bit "
|
||||
+ (captureFormat.Encoding == WaveFormatEncoding.IeeeFloat ? "float" : captureFormat.Encoding.ToString());
|
||||
|
||||
buffer = new BufferedWaveProvider(captureFormat)
|
||||
{
|
||||
ReadFully = true,
|
||||
DiscardOnBufferOverflow = true,
|
||||
BufferDuration = TimeSpan.FromMilliseconds(RingBufferMs),
|
||||
};
|
||||
|
||||
ISampleProvider sp = buffer.ToSampleProvider();
|
||||
if (sp.WaveFormat.SampleRate != MixSampleRate)
|
||||
{
|
||||
sp = new WdlResamplingSampleProvider(sp, MixSampleRate);
|
||||
}
|
||||
if (sp.WaveFormat.Channels != MixChannels)
|
||||
{
|
||||
sp = new StereoMixDownSampleProvider(sp);
|
||||
}
|
||||
Provider = sp;
|
||||
|
||||
capture.DataAvailable += OnDataAvailable;
|
||||
capture.RecordingStopped += OnRecordingStopped;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
try
|
||||
{
|
||||
capture.StartRecording();
|
||||
onDiagnostic?.Invoke($"capture started \"{Name}\" ({Kind}) at {CaptureFormatDescription}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex.Message;
|
||||
onDiagnostic?.Invoke($"capture start failed for \"{Name}\": {ex.GetType().Name}: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
try { capture.StopRecording(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
capture.DataAvailable -= OnDataAvailable;
|
||||
capture.RecordingStopped -= OnRecordingStopped;
|
||||
capture.Dispose();
|
||||
}
|
||||
|
||||
private void OnDataAvailable(object? sender, WaveInEventArgs e)
|
||||
{
|
||||
Interlocked.Increment(ref callbackCount);
|
||||
Interlocked.Add(ref bytesCaptured, e.BytesRecorded);
|
||||
if (e.BytesRecorded <= 0) return;
|
||||
try
|
||||
{
|
||||
buffer.AddSamples(e.Buffer, 0, e.BytesRecorded);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex.Message;
|
||||
onDiagnostic?.Invoke($"capture buffer error for \"{Name}\": {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRecordingStopped(object? sender, StoppedEventArgs e)
|
||||
{
|
||||
if (e.Exception is not null)
|
||||
{
|
||||
lastError = e.Exception.Message;
|
||||
onDiagnostic?.Invoke($"capture stopped with error for \"{Name}\": {e.Exception.GetType().Name}: {e.Exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Down-mixes any channel layout to stereo. Mono is duplicated to L=R; stereo passes through;
|
||||
/// multi-channel (5.1, 7.1, etc.) takes the front L/R channels (a basic "front-pair" pick,
|
||||
/// not a full ITU down-mix matrix). Same approach as the legacy RSound build.
|
||||
/// </summary>
|
||||
private sealed class StereoMixDownSampleProvider : ISampleProvider
|
||||
{
|
||||
private readonly ISampleProvider source;
|
||||
private float[] sourceBuffer = new float[4096];
|
||||
|
||||
public StereoMixDownSampleProvider(ISampleProvider source)
|
||||
{
|
||||
this.source = source;
|
||||
WaveFormat = WaveFormat.CreateIeeeFloatWaveFormat(source.WaveFormat.SampleRate, 2);
|
||||
}
|
||||
|
||||
public WaveFormat WaveFormat { get; }
|
||||
|
||||
public int Read(float[] buffer, int offset, int count)
|
||||
{
|
||||
var frames = count / 2;
|
||||
var sourceChannels = source.WaveFormat.Channels;
|
||||
var sourceFloats = frames * sourceChannels;
|
||||
if (sourceBuffer.Length < sourceFloats) sourceBuffer = new float[sourceFloats];
|
||||
var read = source.Read(sourceBuffer, 0, sourceFloats) / Math.Max(sourceChannels, 1);
|
||||
var written = 0;
|
||||
for (var i = 0; i < read; i++)
|
||||
{
|
||||
if (sourceChannels == 1)
|
||||
{
|
||||
var s = sourceBuffer[i];
|
||||
buffer[offset + written++] = s;
|
||||
buffer[offset + written++] = s;
|
||||
}
|
||||
else
|
||||
{
|
||||
buffer[offset + written++] = sourceBuffer[i * sourceChannels];
|
||||
buffer[offset + written++] = sourceBuffer[i * sourceChannels + 1];
|
||||
}
|
||||
}
|
||||
if (written < count) Array.Clear(buffer, offset + written, count - written);
|
||||
return count;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// Capture backend that runs a WASAPI <see cref="MixingEngine"/> and the persistent
|
||||
/// <see cref="AsioCaptureBackend"/> owned by <see cref="AudioSender"/> in parallel, as two
|
||||
/// independent lanes — each producing its own PCM stream for its own <see cref="SenderLane"/>.
|
||||
///
|
||||
/// Two pipeline shapes are reachable today:
|
||||
/// <list type="bullet">
|
||||
/// <item>WasapiOnly: WASAPI child only, no ASIO in the path. Used when no ASIO driver is
|
||||
/// selected (or none is installed). Lowest latency for WASAPI-only setups.</item>
|
||||
/// <item>BothIndependent: WASAPI child + persistent ASIO child running side by side. Each
|
||||
/// delivers samples to its own callback; there is no mix loop, no shared buffer, no
|
||||
/// tee. ASIO keeps its native sub-5 ms pipeline; WASAPI keeps its WASAPI-event rate.
|
||||
/// The legacy <c>AudioMode.Both</c> tee-style mode and <c>AudioMode.AsioOnly</c> are
|
||||
/// no longer reachable from the UI; their enum values remain in
|
||||
/// <see cref="AudioMode"/> for back-compat but produce nothing here.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal sealed class CompositeCaptureBackend : ICaptureBackend
|
||||
{
|
||||
// WASAPI lane callback. In WasapiOnly this is the only callback in use; in BothIndependent
|
||||
// it is specifically the WASAPI lane (the ASIO lane has its own callback below).
|
||||
private readonly Action<ReadOnlyMemory<float>> onMixedSamples;
|
||||
// ASIO lane callback. Only meaningful in BothIndependent (passed but unused in WasapiOnly,
|
||||
// where the persistent ASIO instance is disposed by AudioSender).
|
||||
private readonly Action<ReadOnlyMemory<float>>? onAsioLaneSamples;
|
||||
private readonly Action<string>? onDiagnostic;
|
||||
private readonly object gate = new();
|
||||
|
||||
// WASAPI child. Normally a MixingEngine (timer-driven, supports N sources); swapped to
|
||||
// PushModeWasapiBackend in Start() when useTightLatencyWasapi is true AND there is exactly
|
||||
// one WASAPI source. Push-mode lets the WASAPI capture event drive the encoder/UDP-send
|
||||
// pipeline directly, eliminating ~6 ms of Stopwatch+WaitHandle scheduler jitter that's
|
||||
// otherwise visible in the receiver as maxGapMs spikes. Multi-source push mode isn't
|
||||
// supported (rendezvous-of-N-callback-streams problem) — multi-source falls back to
|
||||
// MixingEngine.
|
||||
private ICaptureBackend? wasapi;
|
||||
// ASIO child. BORROWED — AudioSender owns the persistent instance and keeps the driver
|
||||
// open across audio-mode rebuilds (so Audient and similar drivers don't get a rapid
|
||||
// close+reopen, which they hate). The composite uses this reference but does NOT
|
||||
// dispose it; AudioSender disposes on app shutdown or driver change.
|
||||
private readonly AsioCaptureBackend? asio;
|
||||
private readonly string? asioDriverName;
|
||||
private readonly AudioMode mode;
|
||||
private readonly bool useTightLatencyWasapi;
|
||||
|
||||
private List<CaptureSourceSpec> wasapiSpecs = [];
|
||||
private List<CaptureSourceSpec> asioSpecs = [];
|
||||
private bool started;
|
||||
|
||||
public CompositeCaptureBackend(AudioMode mode, string? asioDriverName, Action<ReadOnlyMemory<float>> onMixedSamples, Action<ReadOnlyMemory<float>>? onAsioLaneSamples, AsioCaptureBackend? injectedAsio, Action<string>? onDiagnostic = null, bool useTightLatencyWasapi = false)
|
||||
{
|
||||
this.onMixedSamples = onMixedSamples;
|
||||
this.onAsioLaneSamples = onAsioLaneSamples;
|
||||
this.onDiagnostic = onDiagnostic;
|
||||
this.asioDriverName = asioDriverName;
|
||||
this.mode = mode;
|
||||
this.useTightLatencyWasapi = useTightLatencyWasapi;
|
||||
|
||||
// Legacy enum values (AsioOnly, Both) are no longer produced by the UI but might
|
||||
// arrive here from in-flight callers. Coerce them into reachable modes: a non-WASAPI
|
||||
// request without a driver demotes to WasapiOnly; a non-WASAPI request with a driver
|
||||
// is treated as BothIndependent (the only ASIO-using mode now).
|
||||
if (mode != AudioMode.WasapiOnly)
|
||||
{
|
||||
if (string.IsNullOrEmpty(asioDriverName) || injectedAsio is null)
|
||||
{
|
||||
this.mode = mode = AudioMode.WasapiOnly;
|
||||
}
|
||||
else if (mode != AudioMode.BothIndependent)
|
||||
{
|
||||
this.mode = mode = AudioMode.BothIndependent;
|
||||
}
|
||||
}
|
||||
|
||||
// Always build the WASAPI lane (it is the WasapiOnly callback path, and the WASAPI
|
||||
// lane in BothIndependent). Push-mode swap, if applicable, happens in Start().
|
||||
wasapi = new MixingEngine(onMixedSamples, msg => onDiagnostic?.Invoke($"wasapi: {msg}"));
|
||||
|
||||
// Borrow the persistent ASIO instance only in BothIndependent. AudioSender already
|
||||
// pointed its callback at the right lane via SetCallback before constructing us.
|
||||
if (mode == AudioMode.BothIndependent)
|
||||
{
|
||||
asio = injectedAsio;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsRunning => started;
|
||||
public long TotalCaptureCallbacks => (wasapi?.TotalCaptureCallbacks ?? 0) + (asio?.TotalCaptureCallbacks ?? 0);
|
||||
public long TotalCaptureBytes => (wasapi?.TotalCaptureBytes ?? 0) + (asio?.TotalCaptureBytes ?? 0);
|
||||
public string? FirstCaptureFormatDescription => asio?.FirstCaptureFormatDescription ?? wasapi?.FirstCaptureFormatDescription;
|
||||
public string? FirstCaptureLastError => asio?.FirstCaptureLastError ?? wasapi?.FirstCaptureLastError;
|
||||
// ClippedSampleCount lived on the (now-removed) classic-Both mix loop; the per-lane
|
||||
// BothIndependent pipeline has no shared mix bus to clip. Kept as 0 so any UI binding
|
||||
// that still reads it doesn't NRE.
|
||||
public long ClippedSampleCount => 0;
|
||||
|
||||
/// <summary>Worst callback-gap across both inner backends. We have to take from BOTH (so
|
||||
/// each inner's counter resets), then return the larger — otherwise the unread inner
|
||||
/// would just keep accumulating its max forever.</summary>
|
||||
public int TakeMaxCallbackGapMs()
|
||||
{
|
||||
var w = wasapi?.TakeMaxCallbackGapMs() ?? 0;
|
||||
var a = asio?.TakeMaxCallbackGapMs() ?? 0;
|
||||
return Math.Max(w, a);
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> ActiveSourceNames
|
||||
{
|
||||
get
|
||||
{
|
||||
var combined = new List<string>();
|
||||
if (wasapi is not null) combined.AddRange(wasapi.ActiveSourceNames);
|
||||
if (asio is not null) combined.AddRange(asio.ActiveSourceNames);
|
||||
return combined;
|
||||
}
|
||||
}
|
||||
|
||||
public void Start(IReadOnlyList<CaptureSourceSpec> specs)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (started) StopInternal();
|
||||
(wasapiSpecs, asioSpecs) = SplitSpecs(specs);
|
||||
|
||||
// Push-mode WASAPI selection. Lets the WASAPI capture event drive the encoder/UDP
|
||||
// send pipeline directly, eliminating ~6 ms of Stopwatch+WaitHandle scheduler
|
||||
// jitter. Conditions: tight-latency requested, and exactly one WASAPI source
|
||||
// (multi-source needs the rendezvous logic in MixingEngine). Applies equally in
|
||||
// WasapiOnly and BothIndependent — in either, the WASAPI lane is single-source
|
||||
// when the user has ticked one input.
|
||||
var wantPushMode = useTightLatencyWasapi && wasapiSpecs.Count == 1;
|
||||
var currentIsPush = wasapi is PushModeWasapiBackend;
|
||||
if (wantPushMode != currentIsPush)
|
||||
{
|
||||
try { wasapi?.Dispose(); } catch { /* ignore */ }
|
||||
if (wantPushMode)
|
||||
{
|
||||
wasapi = new PushModeWasapiBackend(onMixedSamples, msg => onDiagnostic?.Invoke($"wasapi: {msg}"));
|
||||
onDiagnostic?.Invoke("wasapi backend: switched to push-mode (audio-clock-locked, single-source)");
|
||||
}
|
||||
else
|
||||
{
|
||||
wasapi = new MixingEngine(onMixedSamples, msg => onDiagnostic?.Invoke($"wasapi: {msg}"));
|
||||
onDiagnostic?.Invoke("wasapi backend: switched to mix-engine (timer-driven, multi-source capable)");
|
||||
}
|
||||
}
|
||||
|
||||
wasapi!.Start(wasapiSpecs);
|
||||
// ASIO child is BORROWED from AudioSender. If the driver is already open from a
|
||||
// previous engine instance we want UpdateSources (which won't close it) rather
|
||||
// than Start (which would Stop+Open and trigger the close+reopen hang on Audient).
|
||||
// The callback was already wired to the correct lane by EnsurePersistentAsioLocked.
|
||||
if (asio is not null)
|
||||
{
|
||||
if (asio.IsRunning) asio.UpdateSources(asioSpecs);
|
||||
else asio.Start(asioSpecs);
|
||||
}
|
||||
started = true;
|
||||
onDiagnostic?.Invoke($"composite capture started: wasapi={wasapiSpecs.Count} sources, asio={asioSpecs.Count} sources, mode={ModeLabel()}{(wantPushMode ? " [wasapi push]" : "")}");
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateSources(IReadOnlyList<CaptureSourceSpec> specs)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (!started)
|
||||
{
|
||||
Start(specs);
|
||||
return;
|
||||
}
|
||||
var (newWasapi, newAsio) = SplitSpecs(specs);
|
||||
|
||||
// If push-mode applicability changes (single WASAPI source toggled on/off), the
|
||||
// backend has to swap. PushModeWasapiBackend supports only one source. Full
|
||||
// restart is acceptable here — changing source count mid-session is rare.
|
||||
var wouldBePush = useTightLatencyWasapi && newWasapi.Count == 1;
|
||||
var isPush = wasapi is PushModeWasapiBackend;
|
||||
if (wouldBePush != isPush)
|
||||
{
|
||||
onDiagnostic?.Invoke($"wasapi backend: source count changed ({wasapiSpecs.Count}→{newWasapi.Count}), restarting to switch backend");
|
||||
StopInternal();
|
||||
Start(specs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (wasapi is not null && !SpecsEqual(wasapiSpecs, newWasapi))
|
||||
{
|
||||
wasapi.UpdateSources(newWasapi);
|
||||
wasapiSpecs = newWasapi;
|
||||
}
|
||||
if (asio is not null && !SpecsEqual(asioSpecs, newAsio))
|
||||
{
|
||||
asio.UpdateSources(newAsio);
|
||||
asioSpecs = newAsio;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string ModeLabel() => mode switch
|
||||
{
|
||||
AudioMode.WasapiOnly => "fast (WASAPI direct)",
|
||||
AudioMode.BothIndependent => "independent lanes (WASAPI + ASIO, no mix)",
|
||||
_ => mode.ToString(),
|
||||
};
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
lock (gate) StopInternal();
|
||||
}
|
||||
|
||||
private void StopInternal()
|
||||
{
|
||||
if (!started) return;
|
||||
try { wasapi?.Stop(); } catch { /* ignore */ }
|
||||
// ASIO child is NEVER stopped here — it's the persistent instance owned by AudioSender
|
||||
// and kept alive across engine rebuilds. Stopping it would force a close+reopen that
|
||||
// Audient (and similar drivers) hang on for ~5 s. AudioSender disposes it on app
|
||||
// shutdown or driver change.
|
||||
started = false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
try { wasapi?.Dispose(); } catch { /* ignore */ }
|
||||
// ASIO child not disposed — see StopInternal above.
|
||||
}
|
||||
|
||||
private static (List<CaptureSourceSpec> wasapi, List<CaptureSourceSpec> asio) SplitSpecs(IReadOnlyList<CaptureSourceSpec> specs)
|
||||
{
|
||||
var wasapi = new List<CaptureSourceSpec>();
|
||||
var asio = new List<CaptureSourceSpec>();
|
||||
foreach (var spec in specs)
|
||||
{
|
||||
if (AsioDeviceId.TryParse(spec.DeviceId, out _)) asio.Add(spec);
|
||||
else wasapi.Add(spec);
|
||||
}
|
||||
return (wasapi, asio);
|
||||
}
|
||||
|
||||
private static bool SpecsEqual(IReadOnlyList<CaptureSourceSpec> a, IReadOnlyList<CaptureSourceSpec> b)
|
||||
{
|
||||
if (a.Count != b.Count) return false;
|
||||
for (var i = 0; i < a.Count; i++)
|
||||
{
|
||||
if (a[i].DeviceId != b[i].DeviceId || a[i].Kind != b[i].Kind) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the capture-side audio backend so <see cref="AudioSender"/> can be wired
|
||||
/// to either a WASAPI implementation (today's <see cref="MixingEngine"/>) or an ASIO
|
||||
/// implementation (<see cref="AsioCaptureBackend"/>) without caring which is in use.
|
||||
///
|
||||
/// Both backends produce 48 kHz stereo float frames via the constructor-supplied
|
||||
/// <c>onMixedSamples</c> callback and accept the same <see cref="CaptureSourceSpec"/> identity
|
||||
/// model. ASIO specs use a synthetic <see cref="CaptureSourceSpec.DeviceId"/> of the form
|
||||
/// <c>"asio:<driver-name>|<channel-pair-index>"</c>; WASAPI specs use the
|
||||
/// MMDevice ID.
|
||||
/// </summary>
|
||||
internal interface ICaptureBackend : IDisposable
|
||||
{
|
||||
bool IsRunning { get; }
|
||||
|
||||
/// <summary>Total capture callback count across all active sources (WASAPI) or the ASIO
|
||||
/// driver's input-callback count.</summary>
|
||||
long TotalCaptureCallbacks { get; }
|
||||
|
||||
long TotalCaptureBytes { get; }
|
||||
|
||||
/// <summary>Brief format description of the first source (e.g. "96000 Hz, 2 ch, 32-bit
|
||||
/// float"). Used in diagnostic logs.</summary>
|
||||
string? FirstCaptureFormatDescription { get; }
|
||||
|
||||
string? FirstCaptureLastError { get; }
|
||||
|
||||
/// <summary>Cumulative count of samples that hit the soft-limiter / hard-clamp at the
|
||||
/// encoder boundary. Helpful to know if the source mix is hot enough to need attenuation.</summary>
|
||||
long ClippedSampleCount { get; }
|
||||
|
||||
/// <summary>Friendly names of currently-active sources for diagnostic columns.</summary>
|
||||
IReadOnlyList<string> ActiveSourceNames { get; }
|
||||
|
||||
/// <summary>Largest observed gap (in milliseconds) between consecutive capture callbacks
|
||||
/// since the last call. Resets to zero on read. Exposed so the periodic sender diagnostic
|
||||
/// can surface "the audio capture path stalled for 19 ms" — which on a smoothly-running
|
||||
/// backend should be ≈ buffer-period, but spikes up when GC, USB, or scheduler hiccups
|
||||
/// pause the capture thread. A receiver-side gap > 5–10 ms with otherwise clean network
|
||||
/// is almost always traceable to this value spiking on the sender. Backends that don't
|
||||
/// support per-callback timing (e.g. trivial test backends) may return 0.</summary>
|
||||
int TakeMaxCallbackGapMs();
|
||||
|
||||
void Start(IReadOnlyList<CaptureSourceSpec> specs);
|
||||
|
||||
/// <summary>Live-update of the active source set without stopping the mix loop. Adds/removes
|
||||
/// only the sources that actually changed. Behaviour parity expected from both backends.</summary>
|
||||
void UpdateSources(IReadOnlyList<CaptureSourceSpec> specs);
|
||||
|
||||
void Stop();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using NAudio.CoreAudioApi;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// WasapiLoopbackCapture variant that uses event-sync (interrupt-driven) callbacks with a
|
||||
/// short audio buffer. NAudio's default <see cref="WasapiLoopbackCapture"/> polls every
|
||||
/// half-buffer (~50 ms with the default 100 ms buffer), which delivers audio in noticeable
|
||||
/// bursts and blows up the receiver's playout buffer headroom requirement.
|
||||
///
|
||||
/// With event sync + 10 ms buffer, callbacks fire at the device period (~10 ms typical),
|
||||
/// each carrying ~10 ms of audio. Far smoother. This is what the older RSound build used,
|
||||
/// minus the layers of subsequent abstraction.
|
||||
/// </summary>
|
||||
internal sealed class LowLatencyWasapiLoopbackCapture : WasapiCapture
|
||||
{
|
||||
public LowLatencyWasapiLoopbackCapture(MMDevice device, int audioBufferMilliseconds = 10)
|
||||
: base(device, useEventSync: true, audioBufferMillisecondsLength: Math.Clamp(audioBufferMilliseconds, 5, 200))
|
||||
{
|
||||
}
|
||||
|
||||
protected override AudioClientStreamFlags GetAudioClientStreamFlags() => AudioClientStreamFlags.Loopback;
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
using System.Diagnostics;
|
||||
using NAudio.CoreAudioApi;
|
||||
using NAudio.Wave;
|
||||
using NAudio.Wave.SampleProviders;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// Owns N <see cref="CaptureSource"/> objects + an NAudio <see cref="MixingSampleProvider"/> +
|
||||
/// a 10 ms mix tick. Each tick pulls one frame's worth of mixed 48 kHz stereo float samples
|
||||
/// from the mix bus and hands it to <see cref="OnMixedSamples"/>, which the caller wires into
|
||||
/// the encoder/UDP path.
|
||||
///
|
||||
/// Architecture rationale (validated by external research, see notes in CaptureSource.cs):
|
||||
/// • Separate WASAPI captures, each into a buffered ring, all converted to a common 48 kHz
|
||||
/// stereo float format, then summed via NAudio's MixingSampleProvider — the canonical
|
||||
/// pattern (https://www.markheath.net/post/mixing-and-looping-with-naudio).
|
||||
/// • Loopback captures only fire callbacks when something is rendering on the device. Without
|
||||
/// a continuous render stream, the mic capture (which always fires) and the loopback (which
|
||||
/// intermittently fires) desync and the mix gets gaps — see naudio/NAudio#1110. The sender
|
||||
/// starts a <see cref="SilentRenderKeepAlive"/> on every loopback source's device to keep
|
||||
/// callbacks firing continuously.
|
||||
/// • Per-source clock drift between independent audio devices is unavoidable across long
|
||||
/// sessions. The 250 ms ring + DiscardOnBufferOverflow tolerates it for realistic
|
||||
/// conversation lengths. A proper drift-correcting micro-resample is a future addition.
|
||||
///
|
||||
/// Source-list changes are LIVE: <see cref="UpdateSources"/> diffs the desired set against the
|
||||
/// active set and only adds/removes the sources that actually changed, using NAudio's
|
||||
/// AddMixerInput / RemoveMixerInput. The mix loop never pauses; the encoder's streamId stays
|
||||
/// the same; the receiver doesn't re-init its playout. This is what stops a checkbox toggle
|
||||
/// from causing a 60 ms gap + receiver underrun + auto-tune freakout.
|
||||
///
|
||||
/// The mix-tick loop runs on its own task with Stopwatch-based scheduling for jitter-tolerant
|
||||
/// 10 ms timing — better than System.Threading.Timer or Sleep-based loops.
|
||||
/// </summary>
|
||||
internal sealed class MixingEngine : ICaptureBackend
|
||||
{
|
||||
private const int MixSampleRate = 48000;
|
||||
private const int MixChannels = 2;
|
||||
private const int MixTickMs = 10;
|
||||
private const int MixSamplesPerTick = MixSampleRate * MixChannels * MixTickMs / 1000; // 960 floats
|
||||
|
||||
private readonly Action<ReadOnlyMemory<float>> onMixedSamples;
|
||||
private readonly Action<string>? onDiagnostic;
|
||||
private readonly object gate = new();
|
||||
|
||||
private readonly List<ActiveSource> active = [];
|
||||
private MixingSampleProvider? mixer;
|
||||
private float[] mixScratch = new float[MixSamplesPerTick];
|
||||
private CancellationTokenSource? cts;
|
||||
private Task? mixTask;
|
||||
|
||||
private long clippedSampleCount;
|
||||
private long mixTickCount;
|
||||
|
||||
public MixingEngine(Action<ReadOnlyMemory<float>> onMixedSamples, Action<string>? onDiagnostic = null)
|
||||
{
|
||||
this.onMixedSamples = onMixedSamples;
|
||||
this.onDiagnostic = onDiagnostic;
|
||||
}
|
||||
|
||||
public bool IsRunning => mixTask is { IsCompleted: false };
|
||||
public long ClippedSampleCount => Interlocked.Read(ref clippedSampleCount);
|
||||
public long MixTickCount => Interlocked.Read(ref mixTickCount);
|
||||
|
||||
public long TotalCaptureCallbacks
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var a in active) total += a.Source.CallbackCount;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public long TotalCaptureBytes
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
long total = 0;
|
||||
foreach (var a in active) total += a.Source.BytesCaptured;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>MixingEngine doesn't track per-callback timing — its mix tick is timer-driven
|
||||
/// rather than callback-driven, so the metric isn't directly meaningful here. Returning 0
|
||||
/// is fine: the sender's diag log treats "0 means n/a or no spike". Tight Latency mode in
|
||||
/// WASAPI uses <see cref="PushModeWasapiBackend"/> instead, which is callback-driven.</summary>
|
||||
public int TakeMaxCallbackGapMs() => 0;
|
||||
|
||||
public string? FirstCaptureFormatDescription
|
||||
{
|
||||
get { lock (gate) return active.Count == 0 ? null : active[0].Source.CaptureFormatDescription; }
|
||||
}
|
||||
|
||||
public string? FirstCaptureLastError
|
||||
{
|
||||
get { lock (gate) return active.Count == 0 ? null : active[0].Source.LastError; }
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> ActiveSourceNames
|
||||
{
|
||||
get { lock (gate) return active.Select(a => a.Source.Name).ToList(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the mix loop with the given initial source set. If already running, the existing
|
||||
/// loop is stopped first. After Start, <see cref="UpdateSources"/> can be called to add/remove
|
||||
/// sources without interrupting the loop.
|
||||
/// </summary>
|
||||
public void Start(IReadOnlyList<CaptureSourceSpec> specs)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (IsRunning) StopInternal();
|
||||
if (specs.Count == 0) return;
|
||||
|
||||
var mixFormat = WaveFormat.CreateIeeeFloatWaveFormat(MixSampleRate, MixChannels);
|
||||
mixer = new MixingSampleProvider(mixFormat) { ReadFully = true };
|
||||
|
||||
foreach (var spec in specs)
|
||||
{
|
||||
var entry = OpenSource(spec);
|
||||
if (entry is null) continue;
|
||||
mixer.AddMixerInput(entry.Source.Provider);
|
||||
active.Add(entry);
|
||||
}
|
||||
|
||||
if (active.Count == 0)
|
||||
{
|
||||
onDiagnostic?.Invoke("mixer: no sources opened — staying stopped");
|
||||
mixer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var a in active)
|
||||
{
|
||||
try { a.Source.Start(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
onDiagnostic?.Invoke($"mixer: source \"{a.Source.Name}\" failed to start: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
Interlocked.Exchange(ref clippedSampleCount, 0);
|
||||
Interlocked.Exchange(ref mixTickCount, 0);
|
||||
cts = new CancellationTokenSource();
|
||||
mixTask = Task.Run(() => MixLoop(cts.Token));
|
||||
onDiagnostic?.Invoke($"mixer started with {active.Count} source(s): [{string.Join(", ", active.Select(a => $"\"{a.Source.Name}\" ({a.Source.Kind})"))}]");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Live add/remove of sources without stopping the mix loop. Diffs the desired specs
|
||||
/// against the currently active set: removes those no longer wanted (RemoveMixerInput +
|
||||
/// dispose), adds those newly wanted (open + AddMixerInput + start). The mix loop continues
|
||||
/// reading uninterrupted from whatever is currently in the mixer.
|
||||
/// </summary>
|
||||
public void UpdateSources(IReadOnlyList<CaptureSourceSpec> specs)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
// If the engine was started with no sources (specs.Count==0 returns early in
|
||||
// Start, so mixTask is never created), a later UpdateSources adding sources used
|
||||
// to silently no-op. That broke the BothIndependent flow where a user starts in
|
||||
// AsioOnly→BothIndependent with no WASAPI ticks, then later ticks a WASAPI source
|
||||
// — the lane would never come alive. Mirror AsioCaptureBackend's pattern: when
|
||||
// not running and the new spec set is non-empty, just delegate to Start. The
|
||||
// existing empty-specs case (still not running, still no sources to add) stays a
|
||||
// no-op as before. 2026-05-11.
|
||||
if (!IsRunning || mixer is null)
|
||||
{
|
||||
if (specs.Count > 0)
|
||||
{
|
||||
Start(specs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var desiredKeys = specs.Select(s => SourceKey(s.DeviceId, s.Kind)).ToHashSet();
|
||||
|
||||
// Remove sources no longer wanted.
|
||||
for (var i = active.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var a = active[i];
|
||||
if (desiredKeys.Contains(SourceKey(a.Source.DeviceId, a.Source.Kind))) continue;
|
||||
try { mixer.RemoveMixerInput(a.Source.Provider); } catch { /* ignore */ }
|
||||
DisposeEntry(a);
|
||||
active.RemoveAt(i);
|
||||
onDiagnostic?.Invoke($"mixer: removed source \"{a.Source.Name}\" ({a.Source.Kind})");
|
||||
}
|
||||
|
||||
// Add new sources.
|
||||
var existingKeys = active.Select(a => SourceKey(a.Source.DeviceId, a.Source.Kind)).ToHashSet();
|
||||
foreach (var spec in specs)
|
||||
{
|
||||
if (existingKeys.Contains(SourceKey(spec.DeviceId, spec.Kind))) continue;
|
||||
var entry = OpenSource(spec);
|
||||
if (entry is null) continue;
|
||||
mixer.AddMixerInput(entry.Source.Provider);
|
||||
active.Add(entry);
|
||||
try
|
||||
{
|
||||
entry.Source.Start();
|
||||
onDiagnostic?.Invoke($"mixer: added source \"{entry.Source.Name}\" ({entry.Source.Kind})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
onDiagnostic?.Invoke($"mixer: source \"{entry.Source.Name}\" failed to start: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
lock (gate) StopInternal();
|
||||
}
|
||||
|
||||
private void StopInternal()
|
||||
{
|
||||
try { cts?.Cancel(); } catch { /* ignore */ }
|
||||
try { mixTask?.Wait(TimeSpan.FromMilliseconds(500)); } catch { /* ignore */ }
|
||||
cts?.Dispose();
|
||||
cts = null;
|
||||
mixTask = null;
|
||||
|
||||
foreach (var a in active) DisposeEntry(a);
|
||||
active.Clear();
|
||||
mixer = null;
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
/// <summary>
|
||||
/// Opens a single source from a spec: enumerates the device, creates the capture, attaches a
|
||||
/// silence keepalive for loopback sources. Does NOT register with the mixer or start capture
|
||||
/// — caller does that. Returns null on any failure (device gone, format negotiation, etc.)
|
||||
/// after disposing partial state.
|
||||
/// </summary>
|
||||
private ActiveSource? OpenSource(CaptureSourceSpec spec)
|
||||
{
|
||||
MMDevice? device = null;
|
||||
try
|
||||
{
|
||||
using var enumerator = new MMDeviceEnumerator();
|
||||
device = enumerator.GetDevice(spec.DeviceId);
|
||||
var src = new CaptureSource(device, spec.Kind, spec.Name, onDiagnostic);
|
||||
SilentRenderKeepAlive? ka = null;
|
||||
if (spec.Kind == CaptureKind.Loopback)
|
||||
{
|
||||
try
|
||||
{
|
||||
ka = new SilentRenderKeepAlive(device, onDiagnostic);
|
||||
ka.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
onDiagnostic?.Invoke($"mixer: keepalive failed for \"{spec.Name}\": {ex.GetType().Name}: {ex.Message}");
|
||||
ka = null; // capture still works without it; just less robust on USB devices
|
||||
}
|
||||
}
|
||||
return new ActiveSource { Source = src, KeepAlive = ka, Device = device };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
onDiagnostic?.Invoke($"mixer: failed to open source \"{spec.Name}\" ({spec.Kind}): {ex.GetType().Name}: {ex.Message}");
|
||||
try { device?.Dispose(); } catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Disposes a source bundle in the right order: keepalive first (it shares the device
|
||||
/// with the capture; tearing down the device first leaves the keepalive's WasapiOut talking to
|
||||
/// a freed COM handle), then capture, then device.</summary>
|
||||
private static void DisposeEntry(ActiveSource a)
|
||||
{
|
||||
try { a.KeepAlive?.Dispose(); } catch { /* ignore */ }
|
||||
try { a.Source.Dispose(); } catch { /* ignore */ }
|
||||
try { a.Device.Dispose(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
private static string SourceKey(string deviceId, CaptureKind kind) => $"{deviceId}|{kind}";
|
||||
|
||||
private async Task MixLoop(CancellationToken ct)
|
||||
{
|
||||
// Pro Audio scheduling category if available; falls back gracefully if MMCSS isn't accessible.
|
||||
using var threadBoost = new WindowsAudioThreadBoost("Pro Audio");
|
||||
|
||||
var ticksPerFrame = Stopwatch.Frequency * MixTickMs / 1000;
|
||||
var nextTickStopwatch = Stopwatch.GetTimestamp() + ticksPerFrame;
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
if (nextTickStopwatch > now)
|
||||
{
|
||||
var sleepMs = (int)Math.Clamp((nextTickStopwatch - now) * 1000 / Stopwatch.Frequency, 1, 50);
|
||||
if (WaitHandle.WaitAny(new[] { ct.WaitHandle }, sleepMs) == 0) break;
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we fell catastrophically behind (>4 frames), resync rather than spinning.
|
||||
if (now - nextTickStopwatch > ticksPerFrame * 4)
|
||||
{
|
||||
nextTickStopwatch = now;
|
||||
}
|
||||
nextTickStopwatch += ticksPerFrame;
|
||||
|
||||
var localMixer = mixer;
|
||||
if (localMixer is null) continue;
|
||||
|
||||
var read = localMixer.Read(mixScratch, 0, MixSamplesPerTick);
|
||||
if (read <= 0) continue;
|
||||
|
||||
// Hard-clamp mixed sum to [-1, 1] to prevent encoder clipping when multiple loud
|
||||
// sources sum past unity. Counts clipped samples for diagnostics.
|
||||
long clipped = 0;
|
||||
for (var i = 0; i < read; i++)
|
||||
{
|
||||
var v = mixScratch[i];
|
||||
if (v > 1f) { mixScratch[i] = 1f; clipped++; }
|
||||
else if (v < -1f) { mixScratch[i] = -1f; clipped++; }
|
||||
}
|
||||
if (clipped > 0) Interlocked.Add(ref clippedSampleCount, clipped);
|
||||
Interlocked.Increment(ref mixTickCount);
|
||||
|
||||
onMixedSamples(new ReadOnlyMemory<float>(mixScratch, 0, read));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
onDiagnostic?.Invoke($"mix loop error: {ex.GetType().Name}: {ex.Message}");
|
||||
await Task.Delay(50, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ActiveSource
|
||||
{
|
||||
public required CaptureSource Source { get; init; }
|
||||
public required MMDevice Device { get; init; }
|
||||
public SilentRenderKeepAlive? KeepAlive { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Concentus;
|
||||
using Concentus.Enums;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a Concentus Opus encoder configured for real-time low-latency 48 kHz stereo audio.
|
||||
/// Frame size is selectable at construction (10 ms or 20 ms). Receiver auto-handles whatever
|
||||
/// frame size the sender announces in the format packet — no coordination required.
|
||||
/// </summary>
|
||||
internal sealed class OpusEncoderState : IDisposable
|
||||
{
|
||||
public const int Channels = 2;
|
||||
private const int PacketBufferBytes = 4000;
|
||||
|
||||
private readonly IOpusEncoder encoder;
|
||||
private readonly short[] pcm16Scratch;
|
||||
private readonly byte[] packetScratch = new byte[PacketBufferBytes];
|
||||
|
||||
public int FrameMilliseconds { get; }
|
||||
public int FrameSizePerChannel { get; }
|
||||
|
||||
public OpusEncoderState(int frameMilliseconds, int bitrate)
|
||||
{
|
||||
// RESTRICTED_LOWDELAY supports 2.5/5/10/20 ms frames. 10 ms = lowest practical latency,
|
||||
// 20 ms = same bitrate but more robust to packet loss (each lost packet is half the audio
|
||||
// share). We expose 10 and 20 as the user-selectable choices.
|
||||
FrameMilliseconds = Math.Clamp(frameMilliseconds, 5, 60);
|
||||
FrameSizePerChannel = 48000 * FrameMilliseconds / 1000;
|
||||
pcm16Scratch = new short[FrameSizePerChannel * Channels];
|
||||
|
||||
encoder = OpusCodecFactory.CreateEncoder(48000, Channels, OpusApplication.OPUS_APPLICATION_RESTRICTED_LOWDELAY, TextWriter.Null);
|
||||
encoder.Bitrate = bitrate;
|
||||
encoder.Complexity = 10;
|
||||
encoder.UseVBR = true;
|
||||
// Inband forward error correction. Each encoded packet carries a low-bitrate
|
||||
// copy of the PREVIOUS packet's audio. The receiver only uses it when it
|
||||
// detects a single-packet gap, so on a clean line FEC costs almost nothing
|
||||
// (the encoder gets a few extra bytes of headroom from VBR). On a lossy
|
||||
// link it lets the receiver fill a single missing packet without waiting
|
||||
// — recovery without buffering.
|
||||
encoder.UseInbandFEC = true;
|
||||
// Tells the encoder how aggressively to bias FEC redundancy. 10% is a
|
||||
// sensible value for an internet link via Tailscale: enough redundancy to
|
||||
// recover most one-packet drops, not so much that we sacrifice quality on
|
||||
// a clean network. Concentus accepts 0..100.
|
||||
encoder.PacketLossPercent = 10;
|
||||
}
|
||||
|
||||
/// <summary>Encode one frame at the configured frame size. Returns bytes written.</summary>
|
||||
public int Encode(ReadOnlySpan<float> stereoFloats)
|
||||
{
|
||||
if (stereoFloats.Length != FrameSizePerChannel * Channels)
|
||||
{
|
||||
throw new ArgumentException($"Expected {FrameSizePerChannel * Channels} samples, got {stereoFloats.Length}", nameof(stereoFloats));
|
||||
}
|
||||
|
||||
for (var i = 0; i < stereoFloats.Length; i++)
|
||||
{
|
||||
var clamped = Math.Clamp(stereoFloats[i], -1f, 1f);
|
||||
pcm16Scratch[i] = (short)(clamped * 32767f);
|
||||
}
|
||||
|
||||
return encoder.Encode(pcm16Scratch, FrameSizePerChannel, packetScratch.AsSpan(), packetScratch.Length);
|
||||
}
|
||||
|
||||
public ReadOnlySpan<byte> LastEncoded(int length) => packetScratch.AsSpan(0, length);
|
||||
|
||||
public void Dispose() { /* IOpusEncoder is finalized by GC, no Dispose */ }
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using NAudio.CoreAudioApi;
|
||||
using NAudio.Dsp;
|
||||
using NAudio.Wave;
|
||||
using NAudio.Wave.SampleProviders;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// Single-source WASAPI capture backend with PUSH-DRIVEN timing — the WASAPI capture event
|
||||
/// callback is the encode/send trigger, so the audio pipeline runs on the audio device's
|
||||
/// hardware clock instead of the OS scheduler's Stopwatch+WaitHandle clock.
|
||||
///
|
||||
/// Why this exists: <see cref="MixingEngine"/> uses a Stopwatch-driven 10 ms mix tick that
|
||||
/// pulls audio through a sample-provider chain. That tick is woken by
|
||||
/// <see cref="WaitHandle.WaitAny"/>, which on Windows has ~6 ms of inherent jitter even with
|
||||
/// MMCSS Pro Audio thread priority — visible as <c>maxGapMs=16-20 ms</c> in the receiver
|
||||
/// diagnostics. At 48 kHz device rate that jitter is absorbed by buffer cushion; at 96 kHz the
|
||||
/// extra in-tick resampling stage compounds it and the receiver's buffer ends up sitting
|
||||
/// ~13 ms lower (closer to the underrun edge), producing audible clicks at tight target
|
||||
/// latency.
|
||||
///
|
||||
/// Push mode eliminates the mix tick entirely. The WASAPI callback already fires at the
|
||||
/// device's hardware-clocked period (sub-millisecond precision), and we run the
|
||||
/// resample / stereo-mixdown / soft-clamp / hand-off-to-encoder pipeline directly on the
|
||||
/// callback thread. Same architectural shape as <see cref="AsioCaptureBackend"/> already has.
|
||||
///
|
||||
/// Constraints (deliberate scope reduction so we ship something testable):
|
||||
/// • Single source only. <see cref="Start"/> with multiple specs throws — caller is
|
||||
/// expected to fall back to <see cref="MixingEngine"/> for multi-source. Mixing N
|
||||
/// independent WASAPI capture callbacks needs a rendezvous point that doesn't exist
|
||||
/// in this design.
|
||||
/// • Float-format capture only. Modern WASAPI loopback / shared-mode delivers
|
||||
/// <see cref="WaveFormatEncoding.IeeeFloat"/> 32-bit stereo on every device we've seen.
|
||||
/// Direct-input devices that report int16 will fall through to a diagnostic and the
|
||||
/// callback returns silence; caller can fall back to <see cref="MixingEngine"/> in that
|
||||
/// case (which uses NAudio's <c>ToSampleProvider</c> conversion path that handles all
|
||||
/// formats).
|
||||
/// • Resampling is performed inline using <see cref="WdlResampler"/> (sinc filter). Same
|
||||
/// resampler the existing pull path uses — kept identical to keep audio quality
|
||||
/// comparable.
|
||||
///
|
||||
/// Threading: NAudio's WASAPI callback runs on its own thread, which becomes the audio
|
||||
/// thread for our purposes. <see cref="onMixedSamples"/> is invoked synchronously from
|
||||
/// inside that callback, so the encoder/UDP-send work happens on the capture thread. PCM
|
||||
/// pack and Opus encode are both fast enough not to overrun the next callback period
|
||||
/// (typically < 200 µs of work per 10 ms callback on modern hardware).
|
||||
/// </summary>
|
||||
internal sealed class PushModeWasapiBackend : ICaptureBackend
|
||||
{
|
||||
private const int MixSampleRate = 48000;
|
||||
private const int MixChannels = 2;
|
||||
private const int CaptureBufferMs = 10;
|
||||
|
||||
private readonly Action<ReadOnlyMemory<float>> onMixedSamples;
|
||||
private readonly Action<string>? onDiagnostic;
|
||||
private readonly object gate = new();
|
||||
|
||||
private WasapiCapture? capture;
|
||||
private SilentRenderKeepAlive? keepAlive;
|
||||
private CaptureSourceSpec? activeSpec;
|
||||
private string? captureFormatDescription;
|
||||
private string? lastError;
|
||||
|
||||
private long callbackCount;
|
||||
private long bytesCaptured;
|
||||
private long clippedSampleCount;
|
||||
|
||||
// Resampling state — only allocated when source rate != MixSampleRate.
|
||||
private WdlResampler? resampler;
|
||||
private int sourceSampleRate;
|
||||
private int sourceChannels;
|
||||
|
||||
// Reusable scratch buffers. Sized lazily inside the callback.
|
||||
private float[] sourceFloatScratch = new float[8192];
|
||||
private float[] resampledScratch = new float[8192];
|
||||
private float[] stereoScratch = new float[4096];
|
||||
|
||||
public PushModeWasapiBackend(Action<ReadOnlyMemory<float>> onMixedSamples, Action<string>? onDiagnostic = null)
|
||||
{
|
||||
this.onMixedSamples = onMixedSamples;
|
||||
this.onDiagnostic = onDiagnostic;
|
||||
}
|
||||
|
||||
public bool IsRunning => capture is not null;
|
||||
public long TotalCaptureCallbacks => Interlocked.Read(ref callbackCount);
|
||||
public long TotalCaptureBytes => Interlocked.Read(ref bytesCaptured);
|
||||
public string? FirstCaptureFormatDescription => captureFormatDescription;
|
||||
public string? FirstCaptureLastError => lastError;
|
||||
public long ClippedSampleCount => Interlocked.Read(ref clippedSampleCount);
|
||||
|
||||
public IReadOnlyList<string> ActiveSourceNames =>
|
||||
activeSpec is { } s ? new[] { s.Name } : Array.Empty<string>();
|
||||
|
||||
/// <summary>Push-mode WASAPI is callback-driven and could meaningfully track callback gaps,
|
||||
/// but for now we don't — adding the timing only matters once we're hunting an audible
|
||||
/// jitter issue on the WASAPI tight-latency path. Returns 0 (= no spike). Compare with
|
||||
/// <see cref="AsioCaptureBackend.TakeMaxCallbackGapMs"/> which does track it because that's
|
||||
/// where Ed has been hunting jitter.</summary>
|
||||
public int TakeMaxCallbackGapMs() => 0;
|
||||
|
||||
public void Start(IReadOnlyList<CaptureSourceSpec> specs)
|
||||
{
|
||||
if (specs.Count == 0)
|
||||
{
|
||||
onDiagnostic?.Invoke("push-wasapi: start called with no specs — staying stopped");
|
||||
return;
|
||||
}
|
||||
if (specs.Count > 1)
|
||||
{
|
||||
// Surface this loudly. The caller should have routed multi-source to MixingEngine.
|
||||
throw new InvalidOperationException(
|
||||
$"PushModeWasapiBackend supports only one source, got {specs.Count}. Caller must fall back to MixingEngine for multi-source.");
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (IsRunning) StopInternal();
|
||||
var spec = specs[0];
|
||||
try
|
||||
{
|
||||
using var enumerator = new MMDeviceEnumerator();
|
||||
var device = enumerator.GetDevice(spec.DeviceId);
|
||||
|
||||
capture = spec.Kind == CaptureKind.Loopback
|
||||
? new LowLatencyWasapiLoopbackCapture(device, audioBufferMilliseconds: CaptureBufferMs)
|
||||
: new WasapiCapture(device, useEventSync: true, audioBufferMillisecondsLength: CaptureBufferMs);
|
||||
|
||||
var fmt = capture.WaveFormat;
|
||||
sourceChannels = fmt.Channels;
|
||||
sourceSampleRate = fmt.SampleRate;
|
||||
captureFormatDescription = $"{fmt.SampleRate} Hz, {fmt.Channels} ch, {fmt.BitsPerSample}-bit "
|
||||
+ (fmt.Encoding == WaveFormatEncoding.IeeeFloat ? "float" : fmt.Encoding.ToString());
|
||||
|
||||
if (fmt.Encoding != WaveFormatEncoding.IeeeFloat)
|
||||
{
|
||||
onDiagnostic?.Invoke(
|
||||
$"push-wasapi: source \"{spec.Name}\" reports non-float capture format ({fmt.Encoding}); push mode requires IeeeFloat");
|
||||
lastError = $"unsupported source encoding: {fmt.Encoding}";
|
||||
StopInternal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (fmt.SampleRate != MixSampleRate)
|
||||
{
|
||||
resampler = new WdlResampler();
|
||||
// Same configuration the existing CaptureSource pull path uses — sinc filter,
|
||||
// 64-tap, 32 sub-phase. Quality matches the pull path so any audible
|
||||
// difference vs MixingEngine is timing-driven, not filter-quality-driven.
|
||||
resampler.SetMode(true, 2, true, 64, 32);
|
||||
resampler.SetFilterParms();
|
||||
resampler.SetFeedMode(false); // pull mode internally; we drive the pull from our callback
|
||||
resampler.SetRates(sourceSampleRate, MixSampleRate);
|
||||
}
|
||||
else
|
||||
{
|
||||
resampler = null;
|
||||
}
|
||||
|
||||
if (spec.Kind == CaptureKind.Loopback)
|
||||
{
|
||||
// WASAPI loopback only fires callbacks while something else is rendering on
|
||||
// the device. Same trick MixingEngine uses (see naudio/NAudio#1110).
|
||||
try
|
||||
{
|
||||
keepAlive = new SilentRenderKeepAlive(device, onDiagnostic);
|
||||
keepAlive.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
onDiagnostic?.Invoke($"push-wasapi: keepalive failed for \"{spec.Name}\": {ex.GetType().Name}: {ex.Message}");
|
||||
keepAlive = null;
|
||||
}
|
||||
}
|
||||
|
||||
activeSpec = spec;
|
||||
capture.DataAvailable += OnDataAvailable;
|
||||
capture.RecordingStopped += OnRecordingStopped;
|
||||
capture.StartRecording();
|
||||
onDiagnostic?.Invoke($"push-wasapi started \"{spec.Name}\" ({spec.Kind}) at {captureFormatDescription}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex.Message;
|
||||
onDiagnostic?.Invoke($"push-wasapi start failed for \"{spec.Name}\": {ex.GetType().Name}: {ex.Message}");
|
||||
StopInternal();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateSources(IReadOnlyList<CaptureSourceSpec> specs)
|
||||
{
|
||||
// Single-source backend; live add/remove like MixingEngine V2 isn't applicable.
|
||||
// If the spec list shape is unchanged, no-op. Otherwise restart.
|
||||
var noChange = activeSpec is { } s
|
||||
&& specs.Count == 1
|
||||
&& specs[0].DeviceId == s.DeviceId
|
||||
&& specs[0].Kind == s.Kind;
|
||||
if (noChange) return;
|
||||
lock (gate) StopInternal();
|
||||
if (specs.Count > 0) Start(specs);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
lock (gate) StopInternal();
|
||||
}
|
||||
|
||||
private void StopInternal()
|
||||
{
|
||||
if (capture is not null)
|
||||
{
|
||||
try { capture.DataAvailable -= OnDataAvailable; } catch { /* ignore */ }
|
||||
try { capture.RecordingStopped -= OnRecordingStopped; } catch { /* ignore */ }
|
||||
try { capture.StopRecording(); } catch { /* ignore */ }
|
||||
try { capture.Dispose(); } catch { /* ignore */ }
|
||||
capture = null;
|
||||
}
|
||||
if (keepAlive is not null)
|
||||
{
|
||||
try { keepAlive.Dispose(); } catch { /* ignore */ }
|
||||
keepAlive = null;
|
||||
}
|
||||
resampler = null;
|
||||
activeSpec = null;
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
private void OnDataAvailable(object? sender, WaveInEventArgs e)
|
||||
{
|
||||
Interlocked.Increment(ref callbackCount);
|
||||
Interlocked.Add(ref bytesCaptured, e.BytesRecorded);
|
||||
if (e.BytesRecorded <= 0) return;
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Reinterpret captured bytes as floats. Only IeeeFloat is supported (see Start).
|
||||
var sourceFloatCount = e.BytesRecorded / sizeof(float);
|
||||
if (sourceFloatScratch.Length < sourceFloatCount)
|
||||
sourceFloatScratch = new float[sourceFloatCount];
|
||||
// MemoryMarshal.Cast avoids a copy where layout permits, but e.Buffer is byte[] and we
|
||||
// need the floats indexable so we copy into our scratch. Copy is cheap: 7680 bytes for
|
||||
// 10 ms at 96 kHz stereo float.
|
||||
Buffer.BlockCopy(e.Buffer, 0, sourceFloatScratch, 0, e.BytesRecorded);
|
||||
var sourceFrames = sourceFloatCount / sourceChannels;
|
||||
|
||||
// 2. Resample to MixSampleRate if needed. The resampler is pull-mode; we drive the
|
||||
// pull from our callback. Approximate output frames = input * outRate / inRate.
|
||||
float[] working;
|
||||
int workingFrames;
|
||||
int workingChannels;
|
||||
if (resampler is null)
|
||||
{
|
||||
working = sourceFloatScratch;
|
||||
workingFrames = sourceFrames;
|
||||
workingChannels = sourceChannels;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Compute a generous upper bound on output frames (add a small pad for the
|
||||
// resampler's lookahead). The resampler is fed exactly what it needs and tells us
|
||||
// how many output frames it actually produced; any input we couldn't feed in this
|
||||
// iteration is held in its internal state for next callback.
|
||||
var outBound = (int)Math.Ceiling(sourceFrames * (double)MixSampleRate / sourceSampleRate) + 16;
|
||||
if (resampledScratch.Length < outBound * sourceChannels)
|
||||
resampledScratch = new float[outBound * sourceChannels];
|
||||
|
||||
var inFramesNeeded = resampler.ResamplePrepare(outBound, sourceChannels, out var inBuf, out var inOff);
|
||||
var copyFrames = Math.Min(sourceFrames, inFramesNeeded);
|
||||
if (copyFrames > 0)
|
||||
{
|
||||
Array.Copy(sourceFloatScratch, 0, inBuf, inOff, copyFrames * sourceChannels);
|
||||
}
|
||||
var produced = resampler.ResampleOut(resampledScratch, 0, copyFrames, outBound, sourceChannels);
|
||||
working = resampledScratch;
|
||||
workingFrames = produced;
|
||||
workingChannels = sourceChannels;
|
||||
}
|
||||
|
||||
if (workingFrames <= 0) return;
|
||||
|
||||
// 3. Stereo mixdown. Mono → duplicate; stereo → passthrough; multi-channel → take
|
||||
// front L/R (matches StereoMixDownSampleProvider in CaptureSource).
|
||||
float[] stereo;
|
||||
if (workingChannels == 2)
|
||||
{
|
||||
stereo = working;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (stereoScratch.Length < workingFrames * MixChannels)
|
||||
stereoScratch = new float[workingFrames * MixChannels];
|
||||
if (workingChannels == 1)
|
||||
{
|
||||
for (var i = 0; i < workingFrames; i++)
|
||||
{
|
||||
stereoScratch[i * 2] = working[i];
|
||||
stereoScratch[i * 2 + 1] = working[i];
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var i = 0; i < workingFrames; i++)
|
||||
{
|
||||
stereoScratch[i * 2] = working[i * workingChannels];
|
||||
stereoScratch[i * 2 + 1] = working[i * workingChannels + 1];
|
||||
}
|
||||
}
|
||||
stereo = stereoScratch;
|
||||
}
|
||||
|
||||
// 4. Soft clamp at the encoder boundary (matches MixingEngine / AsioCaptureBackend).
|
||||
var stereoFloatCount = workingFrames * MixChannels;
|
||||
for (var i = 0; i < stereoFloatCount; i++)
|
||||
{
|
||||
var v = stereo[i];
|
||||
if (v > 1f) { stereo[i] = 1f; Interlocked.Increment(ref clippedSampleCount); }
|
||||
else if (v < -1f) { stereo[i] = -1f; Interlocked.Increment(ref clippedSampleCount); }
|
||||
}
|
||||
|
||||
// 5. Hand off to the encoder/UDP-send pipeline. Synchronous on the capture thread.
|
||||
onMixedSamples(new ReadOnlyMemory<float>(stereo, 0, stereoFloatCount));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex.Message;
|
||||
onDiagnostic?.Invoke($"push-wasapi: callback error: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRecordingStopped(object? sender, StoppedEventArgs e)
|
||||
{
|
||||
if (e.Exception is not null)
|
||||
{
|
||||
lastError = e.Exception.Message;
|
||||
onDiagnostic?.Invoke($"push-wasapi: capture stopped with error: {e.Exception.GetType().Name}: {e.Exception.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<RootNamespace>RemSound.Sender</RootNamespace>
|
||||
<AssemblyName>RemSound.Sender</AssemblyName>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\RemSound.Core\RemSound.Core.csproj" />
|
||||
<PackageReference Include="NAudio" Version="2.3.0" />
|
||||
<PackageReference Include="Concentus" Version="2.2.2" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,305 @@
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// One outbound audio stream's worth of state. Each lane owns its own streamId, audio
|
||||
/// sequence counter, frame accumulator, Opus encoder, format-resend timer and PCM frame id.
|
||||
/// AudioSender holds one or more of these — in the three classic modes (WasapiOnly,
|
||||
/// AsioOnly, Both) there is exactly one lane and behaviour is identical to the pre-refactor
|
||||
/// monolithic AudioSender. The BothIndependent mode (Stage 4) instantiates two: a WASAPI
|
||||
/// lane fed by the WASAPI capture child and an ASIO lane fed by the ASIO capture child, each
|
||||
/// producing its own UDP stream on its own streamId, multiplexed by the receiver's
|
||||
/// (endpoint, streamId) keying.
|
||||
///
|
||||
/// Threading: the hot-path methods (<see cref="OnMixedSamples"/> and below) are called from
|
||||
/// the capture engine's callback thread. Each lane has exactly one such thread feeding it.
|
||||
/// Cross-thread state read from AudioSender (codec, mute, opusFrameMs, etc.) goes through
|
||||
/// volatile fields on the owner. Configuration mutations (<see cref="ConfigureCodec"/>,
|
||||
/// <see cref="OnPcmFrameSizeChanged"/>) come from the UI thread; they take the same
|
||||
/// configGate that AudioSender does to serialise streamId rotation against in-flight
|
||||
/// accumulator writes — see AudioSender for the gate.
|
||||
/// </summary>
|
||||
internal sealed class SenderLane
|
||||
{
|
||||
private const int MixSampleRate = 48000;
|
||||
private const int MixChannels = 2;
|
||||
private const int MaxFrameStereoSamples = MixSampleRate * 20 / 1000 * MixChannels; // 1920, Opus 20 ms
|
||||
private const int FormatResendIntervalMs = 250;
|
||||
|
||||
private readonly AudioSender owner;
|
||||
private readonly int opusBitrate;
|
||||
|
||||
// Hot-path scratch. Sized to the largest possible single frame (Opus 20 ms = 1920 stereo
|
||||
// samples). PCM 5 ms uses only the first 480, Opus 10 ms only the first 960. Reusing one
|
||||
// buffer means no realloc on codec change. outboundScratch is per-lane so two lanes don't
|
||||
// step on each other's packet construction.
|
||||
private readonly float[] frameAccumulator = new float[MaxFrameStereoSamples];
|
||||
private int frameAccumulatorWritten;
|
||||
private readonly byte[] outboundScratch = new byte[2048];
|
||||
|
||||
// Per-stream sequence counters. audioSequence is what the receiver's gap-detector and Opus
|
||||
// FEC look at — it must stay monotonic per stream. formatSequence is used for the periodic
|
||||
// format-announce packet; receiver doesn't sequence-check format packets but having a
|
||||
// separate counter keeps the audio FEC clean (see AudioSender.audioSequence comment for
|
||||
// the original reasoning).
|
||||
private uint audioSequence;
|
||||
private uint pcmFrameId;
|
||||
private uint formatSequence;
|
||||
private ushort streamId;
|
||||
private DateTime lastFormatPacketUtc = DateTime.MinValue;
|
||||
|
||||
private OpusEncoderState opusEncoder;
|
||||
private int opusFrameStereoSamples;
|
||||
|
||||
// Which render route this lane announces in its format packets. The receiver reads the
|
||||
// Lane byte on the wire and tags the matching SessionPlayout, which makes PlayoutEngine
|
||||
// route the lane's audio to the corresponding per-route IWaveProvider surface (lane
|
||||
// backends in BothIndependent mode; the legacy Mixed surface in every classic mode).
|
||||
// Default Mixed = classic-mode behaviour, indistinguishable from a pre-2026-05-11 sender.
|
||||
// BothIndependent assigns WasapiLane / AsioLane to the two SenderLanes at mode-change
|
||||
// time via SetRoute.
|
||||
private volatile RenderRoute route = RenderRoute.Mixed;
|
||||
public RenderRoute Route => route;
|
||||
|
||||
public ushort StreamId => streamId;
|
||||
|
||||
public SenderLane(AudioSender owner, int initialOpusFrameMs, int opusBitrate)
|
||||
{
|
||||
this.owner = owner;
|
||||
this.opusBitrate = opusBitrate;
|
||||
opusEncoder = new OpusEncoderState(initialOpusFrameMs, opusBitrate);
|
||||
opusFrameStereoSamples = opusEncoder.FrameSizePerChannel * MixChannels;
|
||||
streamId = NewStreamId();
|
||||
}
|
||||
|
||||
private static ushort NewStreamId() => (ushort)Random.Shared.Next(1, ushort.MaxValue);
|
||||
|
||||
/// <summary>
|
||||
/// Set this lane's render route. Called by AudioSender when audio-mode changes — e.g.
|
||||
/// switching into BothIndependent flips the default lane from Mixed to WasapiLane and
|
||||
/// activates the asio lane as AsioLane. Rotates streamId and forces an immediate format
|
||||
/// re-announce so the receiver opens a fresh session with the new Lane tag rather than
|
||||
/// continuing to route the existing session under the old tag.
|
||||
/// </summary>
|
||||
public void SetRoute(RenderRoute newRoute)
|
||||
{
|
||||
if (route == newRoute) return;
|
||||
route = newRoute;
|
||||
streamId = NewStreamId();
|
||||
lastFormatPacketUtc = DateTime.MinValue;
|
||||
frameAccumulatorWritten = 0;
|
||||
}
|
||||
|
||||
/// <summary>Reset per-lane counters and pick a new streamId. Called from
|
||||
/// <see cref="AudioSender.Start"/> so the receiver sees a fresh session on each start.</summary>
|
||||
public void ResetForStart()
|
||||
{
|
||||
streamId = NewStreamId();
|
||||
audioSequence = 0;
|
||||
pcmFrameId = 0;
|
||||
formatSequence = 0;
|
||||
frameAccumulatorWritten = 0;
|
||||
lastFormatPacketUtc = DateTime.MinValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Codec just changed. Rotates streamId (the receiver opens a fresh session at the new
|
||||
/// format), rebuilds the Opus encoder if Opus is in play, and zeroes the accumulator so
|
||||
/// any half-filled frame from the previous format doesn't leak into the new one.
|
||||
/// </summary>
|
||||
public void OnCodecChanged(AudioTransportCodec newCodec, int opusFrameMs)
|
||||
{
|
||||
if (newCodec == AudioTransportCodec.Opus)
|
||||
{
|
||||
opusEncoder = new OpusEncoderState(opusFrameMs, opusBitrate);
|
||||
opusFrameStereoSamples = opusEncoder.FrameSizePerChannel * MixChannels;
|
||||
}
|
||||
streamId = NewStreamId();
|
||||
lastFormatPacketUtc = DateTime.MinValue;
|
||||
frameAccumulatorWritten = 0;
|
||||
}
|
||||
|
||||
/// <summary>PCM frame size just changed. Rotates streamId so the receiver sees a fresh
|
||||
/// session at the new packet cadence and resets the accumulator. No encoder rebuild —
|
||||
/// Opus is unaffected by the PCM send-rate setting.</summary>
|
||||
public void OnPcmFrameSizeChanged()
|
||||
{
|
||||
streamId = NewStreamId();
|
||||
lastFormatPacketUtc = DateTime.MinValue;
|
||||
frameAccumulatorWritten = 0;
|
||||
}
|
||||
|
||||
// === hot path ===
|
||||
|
||||
public void OnMixedSamples(ReadOnlyMemory<float> stereoFloats)
|
||||
{
|
||||
var span = stereoFloats.Span;
|
||||
if (span.IsEmpty) return;
|
||||
|
||||
// Whole-callback timing — captures encode plus kernel send for the SNAP's emitMs
|
||||
// column. Skipped entirely when diagnostics are off so the audio thread doesn't pay
|
||||
// two Stopwatch reads + a CAS loop per callback for a number nobody is going to log.
|
||||
var diag = RemSound.Core.DiagnosticsGate.Enabled;
|
||||
var emitStart = diag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
||||
EnsureFormatPacketSent();
|
||||
|
||||
switch (owner.Codec)
|
||||
{
|
||||
case AudioTransportCodec.Pcm:
|
||||
ProcessPcm(span);
|
||||
break;
|
||||
case AudioTransportCodec.Opus:
|
||||
ProcessOpus(span);
|
||||
break;
|
||||
}
|
||||
if (diag) owner.RecordEmitTicks(System.Diagnostics.Stopwatch.GetTimestamp() - emitStart);
|
||||
}
|
||||
|
||||
private void ProcessPcm(ReadOnlySpan<float> samples)
|
||||
{
|
||||
// Tight-latency mode: emit each delivered sample buffer as its own packet instead of
|
||||
// accumulating to the PCM frame size. Saves up to (frame_size_ms / 2) of average
|
||||
// accumulator delay. Variable packet size per call. Cap at 240 stereo-frames (5 ms =
|
||||
// 1440 bytes) to stay under MaxAudioPayloadBytes=1454; in normal ASIO buffer sizes
|
||||
// (64/128) this cap is never hit.
|
||||
if (owner.IsTightLatencyEnabled)
|
||||
{
|
||||
const int MaxStereoSamplesPerPacket = 240 * MixChannels;
|
||||
var pos = 0;
|
||||
while (pos < samples.Length)
|
||||
{
|
||||
var chunk = Math.Min(MaxStereoSamplesPerPacket, samples.Length - pos);
|
||||
EmitPcmFrame(samples.Slice(pos, chunk));
|
||||
pos += chunk;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var pcmFrameStereoSamples = owner.PcmFrameStereoSamples;
|
||||
var idx = 0;
|
||||
while (idx < samples.Length)
|
||||
{
|
||||
var spaceLeftForPcmFrame = pcmFrameStereoSamples - frameAccumulatorWritten;
|
||||
var copy = Math.Min(spaceLeftForPcmFrame, samples.Length - idx);
|
||||
samples.Slice(idx, copy).CopyTo(frameAccumulator.AsSpan(frameAccumulatorWritten));
|
||||
frameAccumulatorWritten += copy;
|
||||
idx += copy;
|
||||
|
||||
if (frameAccumulatorWritten == pcmFrameStereoSamples)
|
||||
{
|
||||
EmitPcmFrame(frameAccumulator.AsSpan(0, pcmFrameStereoSamples));
|
||||
frameAccumulatorWritten = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessOpus(ReadOnlySpan<float> samples)
|
||||
{
|
||||
var frameSamples = opusFrameStereoSamples;
|
||||
var idx = 0;
|
||||
while (idx < samples.Length)
|
||||
{
|
||||
var spaceLeft = frameSamples - frameAccumulatorWritten;
|
||||
var copy = Math.Min(spaceLeft, samples.Length - idx);
|
||||
samples.Slice(idx, copy).CopyTo(frameAccumulator.AsSpan(frameAccumulatorWritten));
|
||||
frameAccumulatorWritten += copy;
|
||||
idx += copy;
|
||||
|
||||
if (frameAccumulatorWritten == frameSamples)
|
||||
{
|
||||
EmitOpusFrame(frameAccumulator.AsSpan(0, frameSamples));
|
||||
frameAccumulatorWritten = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EmitPcmFrame(ReadOnlySpan<float> stereoFloats)
|
||||
{
|
||||
var bytesOnWire = stereoFloats.Length * 3;
|
||||
Span<byte> int24 = stackalloc byte[bytesOnWire];
|
||||
if (owner.IsMuted)
|
||||
{
|
||||
int24.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
PcmPack.FloatToInt24LE(stereoFloats, int24);
|
||||
}
|
||||
pcmFrameId++;
|
||||
SendPcmPart(pcmFrameId, partIndex: 0, totalParts: 1, int24);
|
||||
}
|
||||
|
||||
private void EmitOpusFrame(ReadOnlySpan<float> stereoFloats)
|
||||
{
|
||||
ReadOnlySpan<byte> opusBytes;
|
||||
if (owner.IsMuted)
|
||||
{
|
||||
Span<float> silence = stackalloc float[opusFrameStereoSamples];
|
||||
silence.Clear();
|
||||
var muteLen = opusEncoder.Encode(silence);
|
||||
opusBytes = opusEncoder.LastEncoded(muteLen);
|
||||
}
|
||||
else
|
||||
{
|
||||
var len = opusEncoder.Encode(stereoFloats);
|
||||
if (len <= 0) return;
|
||||
opusBytes = opusEncoder.LastEncoded(len);
|
||||
}
|
||||
SendAudio(opusBytes);
|
||||
}
|
||||
|
||||
// === wire path ===
|
||||
|
||||
private void EnsureFormatPacketSent()
|
||||
{
|
||||
if (DateTime.UtcNow - lastFormatPacketUtc < TimeSpan.FromMilliseconds(FormatResendIntervalMs)) return;
|
||||
lastFormatPacketUtc = DateTime.UtcNow;
|
||||
|
||||
// PCM FrameDurationMilliseconds: receiver only uses this for buffer sizing and
|
||||
// diagnostics, not for decode. Round 2.5 ms up to ≥1 to keep the wire field integer.
|
||||
var pcmFrameMs = owner.PcmFrameSamplesPerChannel * 1000 / MixSampleRate;
|
||||
if (pcmFrameMs < 1) pcmFrameMs = 1;
|
||||
var codec = owner.Codec;
|
||||
var opusFrameMs = owner.OpusFrameMilliseconds;
|
||||
// Pass this lane's current Route as the Lane field. In classic-mode senders this is
|
||||
// Mixed and the receiver routes the session to its legacy mix bus; in BothIndependent
|
||||
// senders this is WasapiLane or AsioLane and the receiver routes to the matching
|
||||
// per-route IWaveProvider surface.
|
||||
var format = codec == AudioTransportCodec.Opus
|
||||
? new AudioFormatInfo(48000, 2, 16, 1, 4, 192_000, (int)AudioTransportCodec.Opus, opusFrameMs, route)
|
||||
: new AudioFormatInfo(48000, 2, 24, 1, 6, 288_000, (int)AudioTransportCodec.Pcm, pcmFrameMs, route);
|
||||
|
||||
// Allocate the extended (36-byte) format payload — see RemPacket.FormatPayloadExtendedSize
|
||||
// for the backward-compat contract. Old receivers parse the first 32 bytes and ignore
|
||||
// the rest; new receivers read the Lane byte to decide which render route this stream
|
||||
// belongs to. The Lane value carried here comes from the AudioFormatInfo constructed
|
||||
// above, which currently always sets Mixed for the default lane; Stage 4 will set
|
||||
// WasapiLane / AsioLane on the second lane in BothIndependent mode.
|
||||
Span<byte> packet = stackalloc byte[RemPacket.HeaderSize + RemPacket.FormatPayloadExtendedSize];
|
||||
RemPacket.WriteHeader(packet, RemPacketType.Format, streamId, ++formatSequence);
|
||||
RemPacket.WriteFormatPayload(packet[RemPacket.HeaderSize..], format);
|
||||
owner.SendToAll(packet);
|
||||
}
|
||||
|
||||
private void SendPcmPart(uint frameId, byte partIndex, byte totalParts, ReadOnlySpan<byte> partBytes)
|
||||
{
|
||||
var headerSize = RemPacket.HeaderSize;
|
||||
var subHeaderSize = RemPcmFrame.SubHeaderSize;
|
||||
var totalLen = headerSize + subHeaderSize + partBytes.Length;
|
||||
var dst = outboundScratch.AsSpan(0, totalLen);
|
||||
RemPacket.WriteHeader(dst, RemPacketType.Audio, streamId, ++audioSequence);
|
||||
RemPcmFrame.WriteSubHeader(dst.Slice(headerSize, subHeaderSize), frameId, partIndex, totalParts);
|
||||
partBytes.CopyTo(dst[(headerSize + subHeaderSize)..]);
|
||||
owner.SendToAll(dst);
|
||||
}
|
||||
|
||||
private void SendAudio(ReadOnlySpan<byte> opusBytes)
|
||||
{
|
||||
var totalLen = RemPacket.HeaderSize + opusBytes.Length;
|
||||
var dst = outboundScratch.AsSpan(0, totalLen);
|
||||
RemPacket.WriteHeader(dst, RemPacketType.Audio, streamId, ++audioSequence);
|
||||
opusBytes.CopyTo(dst[RemPacket.HeaderSize..]);
|
||||
owner.SendToAll(dst);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using NAudio.CoreAudioApi;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace RemSound.Sender;
|
||||
|
||||
/// <summary>
|
||||
/// Pins a continuous silent render stream on a WASAPI device so the device stays "warm".
|
||||
/// Some USB audio interfaces (Audient EVO8, RME, Focusrite, etc.) only fire WASAPI loopback
|
||||
/// callbacks when something is actively rendering to the device — when the render endpoint
|
||||
/// goes idle, the loopback path stops delivering frames until an application starts rendering
|
||||
/// again. By pinning a zero-volume silent render stream on the same device we capture from,
|
||||
/// loopback callbacks keep firing regardless of whether other apps are playing audio.
|
||||
///
|
||||
/// Same idea the legacy "silence.exe" used; folded into the sender so it's automatic, sized
|
||||
/// to the device's actual mix format (no resampler stage), and ties to capture lifetime.
|
||||
/// We do not own the MMDevice — AudioSender does — so we never dispose it.
|
||||
/// </summary>
|
||||
internal sealed class SilentRenderKeepAlive : IDisposable
|
||||
{
|
||||
private readonly WasapiOut output;
|
||||
private readonly Action<string>? onDiagnostic;
|
||||
|
||||
public SilentRenderKeepAlive(MMDevice device, Action<string>? onDiagnostic = null)
|
||||
{
|
||||
this.onDiagnostic = onDiagnostic;
|
||||
// Shared mode with a 50 ms buffer. Latency doesn't matter for silence; longer buffers
|
||||
// mean fewer wakeups per second. Event sync still gives us efficient blocking turnover.
|
||||
output = new WasapiOut(device, AudioClientShareMode.Shared, useEventSync: true, latency: 50);
|
||||
output.Init(new SilenceProvider(output.OutputWaveFormat));
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
try
|
||||
{
|
||||
output.Play();
|
||||
onDiagnostic?.Invoke($"silence keepalive started ({output.OutputWaveFormat.SampleRate} Hz, {output.OutputWaveFormat.Channels} ch)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
onDiagnostic?.Invoke($"silence keepalive start failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { output.Stop(); } catch { /* ignore */ }
|
||||
try { output.Dispose(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
private sealed class SilenceProvider(WaveFormat format) : IWaveProvider
|
||||
{
|
||||
public WaveFormat WaveFormat { get; } = format;
|
||||
|
||||
public int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
Array.Clear(buffer, offset, count);
|
||||
return count;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user